基於圖搜索Go建雲頂之弈S1版本最強羈絆

本文但願讀者玩過雲頂之弈,不懂編程的能夠直接拉到最下面去看結論,懂編程的但願你瞭解遞歸、分治、圖、堆這些基本概念,並掌握Python或者Go語言。 代碼已公開在github上:github.com/weiziyoung/… ,轉載請註明來源。node

今天是11月11日,首先恭喜FPX一頓摧枯拉朽橫掃G2, 拿下S賽冠軍!證實了LPL是世界第一賽區,也讓電競做爲一種賽事在這代人心中銘記。本屆S賽結束,也就意味着,S8告一段落,S9即將上線。而云頂之弈做爲今年剛出的新模式,在上週11月6日也發佈了S2元素崛起版本,一時間各類打法也是層出不窮,小編我也是一名忠實的雲頂之弈玩家,但目前尚未玩過S2版本,主要想把這篇文章先寫好分享給想讀的人。python

其實早在今年暑假剛出這個新模式,你們都還不會玩,還在摸索各類陣容的時候,我就在思考一件事——如何經過編程的手段搜索到6人口、7人口、8人口甚至9人口時湊到的最多的羈絆?這種想法來源於一個慘痛的經歷,就是我第一次玩的時候,大概只湊出來了一個3貴族2騎士羈絆,就草草第七名帶走了...當時就以爲這個遊戲太難了,這麼多卡片怎麼能全記住?除了英雄以外,還有裝備合成也跟英雄聯盟差的很遠,但玩個兩三局,大概就明白:git

這個遊戲想吃雞有三個核心——羈絆英雄等級裝備, 三個核心有兩個佔優點,基本能夠達到前四,三個都佔優點,就穩定吃雞了。這裏咱們主要討論的就是去搜索羈絆,從而在這個維度上不吃虧。而裝備這塊比較靠臉,因此不作討論,英雄等級這塊其實能夠根據每張卡在每一個階段出現的機率來估算出來這個陣容成型的難易程度,可是在本片博客裏不作討論,這裏只討論一個問題,就是羈絆程序員

文章大綱

  • 雲頂之弈遊戲簡介
  • 基本算法思路
  • 準備實驗數據
  • 排列組合的原理和實現
  • 用圖下降搜索複雜度
  • 評估函數的設計和實現
  • 最小堆維護Top 100陣容
  • 結果展現
  • 分析與總結

雲頂之弈遊戲簡介

通常讀到這裏的讀者應該都玩過雲頂之弈,但爲了照顧有些只打匹配排位從不下棋的同窗,這裏仍是簡單介紹一下這個遊戲機制。 github

image.png

  • 方框1所在的是小小英雄,就是召喚師啦,好看用的。
  • 方框2是你目前的隊伍,這個隊伍能夠由不一樣英雄組成,可是隊伍規模取決於你等級的高低。
  • 方框3是你候選區的英雄,放一些你暫時不想上場的英雄,固然這個區域大多數是用來合成英雄的,3個相同1星英雄能夠合成成2星,3個相同2星英雄能夠合成爲3星。固然咱們這裏不討論如何優化英雄等級的話題。
  • 方框4是發牌員給你發的牌,還有你目前有多少錢,每回合發牌員會給你發5張牌,你須要用金幣去購買,這裏只須要記住一點,星級越高的英雄越難抽到,而且也越強。
  • 方框5就是咱們的核心——羈絆了,它是根據場上的英雄的種族和職業所肯定的,好比目前場上小炮和男槍能夠組成一個槍手的buff,這個Buff可使得槍手在攻擊時形成2個目標傷害,而劫的話本身是個忍者,因此能夠組成一個忍者buff,它能夠提高本身暴擊概率和攻擊速度。每一個羈絆都有本身的效果,同時,羈絆也有本身的等級,好比當你只有2個槍手的時候,你的槍手可以同時形成2個敵人的傷害,而4個槍手的時候,你能夠再攻擊時同時形成3個目標的傷害;同時羈絆也有範圍,有的羈絆只對單我的有效,好比雙帝國、三貴族、單忍者,大多數羈絆對同種族的有效,好比狂野、槍手、劍士,少數羈絆對隊伍裏全部英雄都有效,好比騎士、法師。

具體的S1版本英雄羈絆圖以下(有一些後期英雄沒加上去,好比潘森、卡薩、海克斯): golang

image.png

總共是56只英雄,大多英雄擁有一個種族,一個職業,船長的職業既是劍士也是槍手,納爾的種族既是約德爾人也是狂野。通常來講,這個遊戲在七人口時陣容成型,這個階段基本能看出誰勝誰負,因此咱們的目的就是選7個英雄,組成羈絆上最強的陣容。web

基本算法思路

就像以前所說的,咱們的目的是在56個英雄裏選n個英雄,而後從裏面選出羈絆最強的前K個。這句話能夠拆分爲這三個問題:算法

  1. 首先,如何讓計算機去自動把全部組合的可能性一個不拉地遍歷出來?不重複也不漏檢?
  2. 其次,給定一個陣容,如何去評判羈絆的強度?
  3. 第三,怎麼去保存前K個羈絆最強的結果?

對於第一個問題來講,不少編程語言都有combination的拓展庫,方便程序員求出一個列表的元素全部的組合可能性。可是這個是個好的方案嘛?真的可行嘛?若是不可行,怎麼去優化?編程

對於第二個問題來講,咱們在評估一個東西,或者說量化一個東西的時候,應該採用哪些指標?羈絆可能是不是意味着羈絆就強?若是不是的話,是否須要引入主觀性的一些指標,好比單個羈絆對英雄的增益程度?另外這個羈絆好成型嘛?是否是容易在組建的半路上暴斃?這些都是須要注意的問題。json

對於第三個問題來講,看起來很容易,但排序真的可行嗎?因爲咱們搜索的結果多達幾百萬個的陣容組合,所有排序後再取前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. 1表明只有一個英雄能吃到這個羈絆buff的效果,典型的好比3貴族、2帝國。
  2. 2表明持有該羈絆的可以吃到這個buff效果,大多數羈絆都屬於這個效果,好比惡魔、冰川、狂野、變形者、刺客、槍手、劍士、4帝國等等。
  3. 3表明隊伍所有均可以吃到這個buff,好比6貴族、騎士、法師這些。
  4. 4表明一個特殊的羈絆範圍,就是護衛了,護衛是除了護衛自己,其周圍的人都能吃到buff。

champions就是持有這個羈絆的全部英雄了,所有羈絆數據在這裏:data/traits.json。這就是咱們如今所能拿到的全部客觀數據,不摻雜任何拍腦殼給的主觀權重。實際上在評估時,這種數據越多越好,主觀性太強的指標例如英雄強度、羈絆強度這種,公說公有理,婆說婆有理,很難有客觀的結論,儘可能少引入到評價體系中。

排列組合的原理和實現

如今咱們有全部英雄了,做爲召喚師,我以爲頗有必要把它們一字排開欣賞欣賞...畢竟S2就看不到他們的絕大多數了。

image.png
因此咱們的任務就是從55個英雄裏面挑出8個英雄,讓他們的羈絆數量最多。因此這是一個排列組合裏的組合問題,能夠根據公式求出組合數量:

C_{n}^{m}=\frac{n!}{m!(n-m)!}

其中n等於55,m等於8,也就是八人口時,須要搜索231917400個不重複的可能性。

如何實現組合呢

最經典的思路就是分治了,看個簡單的問題,好比對[a,b,c,d,e]求個數爲3的全部組合。那麼,咱們首先會先把a取出來,問題簡化成了對[b,c,d,e]求個數爲2的全部組合。其次,咱們把b取出來,問題簡化成了對[c,d,e]求個數爲1的全部組合,這時候問題就簡單了.示意圖以下:

image.png
紅框表示你如今已經選擇的字母,紅框下面的數字表明須要繼續進行組合的元素,到三層結束。 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🐌蝸牛通常的速度吧:

image.png
平均每秒遍歷 36979個結點,搜索6人口的最優羈絆居然要花14分鐘,做爲一個堆效率有追求的程序員,怎麼可以容忍這種事情出現??我只想對這個結果說:
image.png
因此接下來就沒有Python代碼了,一樣的算法用Go跑的話,速度是每秒大約20w個結點, 大概是Python的7倍左右,若是用C++來寫會更快,但若是讓我用C++來寫可能要明年大家才能看到我這篇文章了,因此程序員要在開發效率和運行速度中取得一個平衡:
image.png

用圖下降搜索複雜度

窮舉法的弊端

由以前的公式:

C_{n}^{m}=\frac{n!}{m!(n-m)!}

咱們能夠算出,八人口須要搜索231917400個結點,用Python搜索大概須要1.7個小時左右,用Golang搜索大概須要20分鐘,速度仍是很不夠看,從語言上已經優化不了了,那就從算法上進行優化。

結合這個遊戲,仔細思考一下咱們是否真的須要對56個英雄都組合一遍呢?這麼看不夠直觀,我舉個很是簡單的栗子

image.png
給定圖上的五隻英雄:蜘蛛、蓋倫、浪人、維魯斯、豹女、寒冰,選出三個英雄,目標是讓他們組成的羈絆數量最大,用大腦去看,那結果必定是「蜘蛛、維魯斯、寒冰」,可是,咱們模擬以前窮舉法的過程,首先選出蜘蛛,其次選擇第二位的蓋倫,若是真的有人會在拿到蜘蛛的狀況下去第二位去選擇蓋倫湊羈絆,大概會讓人以爲:
image.png

基於羈絆的思路

正常的人拿完蜘蛛,下一步必定是拿維魯斯或者豹女,拿維魯斯由於恰好能夠湊一個惡魔,維魯斯又是一個比較強的打工二費卡,何樂而不爲?拿豹女是由於後面可能能夠湊3換型,能湊出3換型,前期坦度是妥妥的,因此咱們在拿到蜘蛛的狀況下,不可能去考慮下一步拿蓋倫和狼人,在下一步拿到維魯斯的狀況下,去考慮豹女和寒冰,(思考一下爲何要考慮豹女?),這樣咱們就達到了最多羈絆:雙惡魔加雙寒冰。綜上,咱們簡化搜索的主要邏輯就是每次只選擇與他能產生羈絆的對象,基於這個想法,咱們的搜索就變成:

image.png

而圖就是用來描述每一個對象之間關係的一種數據結構,在這裏,圖用來描述英雄之間的羈絆關係,而圖的表示方法有兩種:鄰接矩陣法和鄰接表法,二者的取捨取決於圖的稀疏程度。將上面官方給的羈絆-英雄圖轉個方式就獲得了英雄-羈絆鄰接矩陣圖(57*57的矩陣,有相同羈絆則輸出1, 沒有則輸出0)由圖中能夠看出,該矩陣爲稀疏矩陣,因此咱們後面用鄰接表法來表示該矩陣):

image.png
另外,全部的英雄都和機器人、浪人、忍者有羈絆,由於隊伍裏只要添上它們任何中的一個,均可覺得羈絆數+1,符合咱們的優化預期。亞索在這裏不是孤兒了。
image.png

那麼怎麼利用這個信息去優化咱們的算法呢?這須要進一步地去理解「組合」搜索究竟作了什麼?是否能夠用圖的方式來進行組合搜索?答案是確定的,以剛纔組合a,b,c,d,e選出3個進行組合爲例,換個思路來想這個事,實際上他們彼此之間也能夠用有向圖來表示:

image.png
因此以前那個組合示意圖,也能夠這麼理解:
image.png
綜上所述,對於組合而言,咱們只要把每一個結點指向起後面的全部結點,而後用普通的圖搜索,就能夠獲得組合的結果。 而利用羈絆圖,咱們能夠不用把每一個結點指向後面的全部結點,相反,咱們只要把每一個結點指向後面全部能跟當前組合產生羈絆的結點就能夠了,注意!不能只考慮和當前結點產生羈絆,而要考慮隊伍裏全部英雄所擁有的全部結點,不然會漏搜索!咱們優化的初衷是,保證搜索結果不變的狀況下,減小沒必要要的搜索,而不能漏搜索。 所以核心搜索代碼以下:

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),大幅度簡化了初始的搜索圖(自行想象一下全部結點鏈接全部後續結點密密麻麻的效果圖)。

image10.png
實際上,我認爲這種啓發式搜索,有點A star搜索的意思在裏面,核心思想就是講後續children進行排序,將預期離目標結果近的放在前面。這裏作的極端了一些,咱們把沒有產生羈絆的後續結點所有咔嚓了,但實際上這並不會形成漏檢(讀者能夠本身實驗一下)

最後,比較一下基於羈絆圖的結點搜索數量和不基於羈絆圖的結點搜索數量,橫座標是人口,縱座標是結點數量,注意一下縱座標的跨度,是指數級別的。

image.png
因此到這裏,這篇博客的核心部分就講完了,基本思想就是利用現有的知識(英雄之間產生的羈絆)來大幅度簡化搜索。

評估函數的設計與實現

以前咱們一直都沒有實現評估函數,其實這個評估函數的設計是很是靈活的,也是玩家能夠加入本身玩遊戲的經驗的一部分。這裏咱們用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,則認爲屬於同一套陣容:

image.png

羈絆數最多的陣容

首先咱們看不可能有錯的一個指標——羈絆數。直觀來講,就是搜索出讓左邊的羈絆燈亮最多的陣容(這種陣容不必定強)

  • 六人口
{
        "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結束,網上並無一篇用圖搜索來組建羈絆陣容的文章,這篇文章就當是彌補這一塊的空白吧,它從另外一個角度去爲咱們推薦了陣容。核心思想就是利用英雄之間的相互羈絆來簡化暴力搜索。

缺陷

實際上我以爲在評估陣容強度的時候,模型仍是過於粗糙的,具體表現以下:

  1. 首先忽視了坦度和輸出的配合這個維度。致使有些推薦陣容全是坦克沒有輸出,有些陣容只有輸出沒有坦克。

  2. 其次忽視了羈絆之間的剋制關係。能夠看到七八人口的時候,計算出來的都是以護衛龍爲核心的陣容,由於護衛羈絆提供的收益範圍很大,但前提條件是你把英雄都集中放護衛周圍,但這種方法其實是被海克斯完克的,因此在實際時間上,護衛buff的收益並無這裏計算中的那麼大。

  3. 忽略了陣過渡的平滑程度。這是這裏存在的最大問題,因爲咱們在評價陣容的時候,給高等級英雄傾向了一些權重,致使陣容中會有數量較多的高費英雄,實際上不考慮陣容成型難易程度的推薦就是在耍流氓。好比潘森剛出來的時候,不少人推薦貴族護衛龍,實際應用上效果並很差。

  4. 沒有考慮英雄升星的難易程度。這個實際上跟上面是一種問題,我在搜索結果裏找賭刺的陣容,直接被排名拍到了40多名,但賭刺絕對是6人口的T1陣容,這裏面的緣由就是刺客的卡費廣泛是低的,致使在這套算法裏賺不到便宜,但其實低費卡更容易到三星,而三星低費卡的強度是高於高費卡的,尤爲是像三星劫這樣的英雄。

    image.png

  5. 沒有考慮金剷剷。由於簡化問題,這裏沒有考慮金剷剷,若是考慮金剷剷的話,搜索空間將會變得極其龐大,至關於爲每一個英雄都給配劍士、刺客、騎士、冰川、約德爾、惡魔這些羈絆。這些加上去之後,複雜度也就跟全搜索差很少了。

踩坑記錄

  1. Golang append函數,函數原型以下:
func append(slice []Type, elems ...Type) []Type 複製代碼

從原型上看是傳入一個切片,和若干須要加入的元素,返回一個切片。但實際上傳入的slice切片在運行的過程當中會被修改,返回的那個切片實際上就是你傳入的slice切片。因此在使用golang裏面的append函數的時候,記得把接受變量設置成你傳入的第一個slice變量,或者使用前對slice進行copy。

  1. 保留前K大個數實際上要用小頂堆,而不是想固然地使用大頂堆。
  2. 在考慮當前英雄的後續結點的時候,不能只考慮當前英雄的羈絆,而要考慮隊伍裏全部英雄的羈絆,不然會漏檢。
相關文章
相關標籤/搜索