一、游戲規則介紹
紙牌接龍是一個很經典的游戲了,相信很多人小時候都玩過。
規則如下:
1,一共52張牌,初始牌堆是1~7張,只有最下面一張是翻開的,下面的牌挪走之后上一張翻開。
2,右上角有24張牌,每次翻開3張,只能操作最上面的一張。
3,不同顏色的牌,若數字相差1,可以接在一起。接在一起的牌可以一起拖動。
4,只有K可以拖到空列上
5,左上角每種花色分別從小到大收集,把52張牌全部收集算作成功
AStar算法原本用於求解最短路徑問題,也適用於很多游戲的求解問題。對於其他類似游戲,稍作修改可以使用。紙牌接龍要100多步才能解出,每步都有若干分支,搜索樹極其龐大,使用深度優先或者廣度優先搜索是行不通的。
二、交互界面設計
設計環境:VS2019,.Net Framework4.7.2
拖入兩個TextBox和三個按鈕,添加按鈕的點擊事件,添加退出事件。
由於計算線程會卡住主線程,需要新開一個計算線程。
上下兩個TextBox的名字分別是textBox_Create和textBox_Result。交互窗口代碼如下(其中很多類還沒有,后面慢慢介紹):
public partial class Form_Main : Form { AStarGameAnalyze analyze_AStar; public Form_Main() { InitializeComponent(); } /// <summary> /// 隨機生成52張牌 /// </summary> private void button_RandomCreate_Click(object sender, EventArgs e) { CardsGameData cardsGame = new CardsGameData(); cardsGame.CreateRandomCards(); //創建一局純隨機游戲 textBox_Create.Text = cardsGame.PrintGameData(); } /// <summary> /// 求解 /// </summary> private void button_Console_Click(object sender, EventArgs e) { string[] data = textBox_Create.Text.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries); if(data.Length<8) { textBox_Result.Text = "行數錯誤"; } else { string[] colData = new string[7]; for (int i = 0; i < colData.Length; i++) { colData[i] = data[i]; } CardsGameData cardsGame = new CardsGameData(colData, data[7]); AbortThread(); //先關閉之前的線程 analyze_AStar = new AStarGameAnalyze(); analyze_AStar.SolveGame(this, cardsGame); } } /// <summary> /// 增加控制台的內容 /// </summary> public void AddConcole(string str,bool needNewLine=true) { textBox_Result.Text += str; if(needNewLine) { textBox_Result.Text += "\r\n"; } } /// <summary> /// 清空控制台 /// </summary> public void ClearConcole() { textBox_Result.Text = ""; } /// <summary> /// 殺死線程按鈕 /// </summary> private void button_Abort_Click(object sender, EventArgs e) { AbortThread(); } /// <summary> /// 關閉窗口 /// </summary> private void Form_Main_FormClosing(object sender, FormClosingEventArgs e) { AbortThread(); } /// <summary> /// 停止計算 /// </summary> public void AbortThread() { analyze_AStar?.StopAnalyze(); } }
需要注意的是,除了“殺死計算線程”按鈕以外,在開始計算之前,還有關閉窗口的時候都要停止計算,別讓野線程在后台掛一大堆。
三,紙牌和牌局類設計,牌局的隨機生成
由於新增節點時需要大量的復制操作,紙牌和牌局類需要設計拷貝構造函數
1,紙牌類Card設計
首先我們需要規定牌的輸出格式,實現String和Card類的轉換,不然也看不懂到底隨機了個什么。規定牌的數字是1-13,即A為1,JQK為11,12,13。規定花色紅桃為T,方塊為F,黑桃為H,梅花為M。(基本都是拼音首字母,紅桃黑桃都是H,紅桃就換成桃桃的首字母了0.0)
例如:紅桃5——T5,梅花Q——M12。
具體代碼如下:
enum CardType { HongTao = 0, FangKuai = 1, HeiTao = 2, MeiHua = 3, Unknown = 4, } class Card { public CardType cardType; public int num; public bool canMove; //是否翻開 public Card() { } /// <summary> /// 拷貝構造 /// </summary> public Card(Card otherCard) { cardType = otherCard.cardType; num = otherCard.num; canMove = otherCard.canMove; } public Card(CardType type,int num) { this.cardType = type; this.num = num; this.canMove = true; } /// <summary> /// 判斷兩張牌是否相同 /// </summary> public static bool IsTwoCardEqual(Card card1,Card card2) { if (card1 == null || card2 == null) { return card1 == null && card2 == null; } else { return card1.cardType == card2.cardType && card1.num == card2.num; } } /// <summary> /// 從字符串解析 /// </summary> public Card(string data,bool canMove=true) { cardType = String2CardType(data[0]); num = Convert.ToInt32(data.Substring(1)); this.canMove = canMove; } public string PrintCard() { string cardData = CardType2String(cardType) + num; return cardData; } /// <summary> /// 判斷兩張牌是否花色不同 /// </summary> public bool IsDifferentColor(Card otherCard) { return IsDifferentColor(otherCard.cardType); } /// <summary> /// 判斷兩張牌是否花色不同 /// </summary> public bool IsDifferentColor(CardType other_CardType) { if (cardType == CardType.HongTao || cardType == CardType.FangKuai) { return other_CardType == CardType.HeiTao || other_CardType == CardType.MeiHua; } else { return other_CardType == CardType.HongTao || other_CardType == CardType.FangKuai; } } /// <summary> /// 判斷上下兩張牌是否是一組 /// </summary> /// <returns></returns> public bool IsOneGroup(Card upCard) { if(upCard.num - this.num == 1) { return IsDifferentColor(upCard); } else { return false; } } /// <summary> /// 類型轉字符串 /// </summary> public static string CardType2String(CardType cardType) { string cardData = ""; switch (cardType) { case CardType.HongTao: { cardData = "T"; break; } case CardType.FangKuai: { cardData = "F"; break; } case CardType.HeiTao: { cardData = "H"; break; } case CardType.MeiHua: { cardData = "M"; break; } } return cardData; } /// <summary> /// 字符串轉紙牌類型 /// </summary> public static CardType String2CardType(char typeStr) { CardType cardType = CardType.Unknown; switch (typeStr) { case 'T': { cardType = CardType.HongTao; break; } case 'F': { cardType = CardType.FangKuai; break; } case 'H': { cardType = CardType.HeiTao; break; } case 'M': { cardType = CardType.MeiHua; break; } } return cardType; } }
2,牌局類CardsGameData設計
變量如下:
private List<List<Card>> cardCols; //紙牌列 private List<Card> cardPile; //紙牌堆 private List<int> CollectAreaTop; //收集區每種花色 private int curPilePos; //當前牌堆翻開的位置(3的倍數) private int curPileTop; //當前牌堆頂 public const int cardTypeNum = 4;
紙牌列:每一列都是一個List<Card>,一共7列。
紙牌堆:右上角的牌堆。
收集區:每一格用一個int表示該花色最大收集到幾了。規定四個收集格子的順序:紅桃,方塊,黑桃,梅花,即紅桃只能放到第0列,方塊只能放到第1列
翻開的位置curPilePos:沒翻是0,翻1次是3,翻2次是6……
牌堆頂curPileTop:實際最上面一張牌。比如說翻了3次,之后挪走一張,就是8。如果遇到已經挪走的牌,則繼續往前挪。反正就是最上面一個能挪動的牌的index
拷貝構造函數和string解析沒啥好說的,打亂牌局使用Knuth-Durstenfeld Shuffle打亂算法,時間復雜度O(n)。我之前寫過一篇博客專門介紹這個,大致思路就是每次隨機一個元素挪到數組最后面,然后縮小隨機范圍。ps:在打亂時並不會考慮紙牌是否已經翻開這種問題。打亂只是為了輸出牌局,真正的牌局是點了求解之后,通過字符串解析的。解析時才設置牌的翻開狀態。
完整代碼如下:
#region 生成與讀取 public CardsGameData() { } /// <summary> /// 深拷貝構造函數 /// </summary> public CardsGameData(CardsGameData otherData) { //拷貝紙牌列表 cardCols = new List<List<Card>>(); foreach (List<Card> item_Col in otherData.cardCols) { List<Card> singleCol = new List<Card>(); foreach (Card item_Card in item_Col) { if(item_Card == null) { singleCol.Add(null); } else { singleCol.Add(new Card(item_Card)); } } cardCols.Add(singleCol); } //拷貝牌堆 cardPile = new List<Card>(); foreach (Card item_Card in otherData.cardPile) { if (item_Card == null) { cardPile.Add(null); } else { cardPile.Add(new Card(item_Card)); } } //拷貝收集區 CollectAreaTop = new List<int>(); foreach (int item in otherData.CollectAreaTop) { CollectAreaTop.Add(item); } //拷貝翻牌狀態 curPilePos = otherData.curPilePos; curPileTop = otherData.curPileTop; } /// <summary> /// 從字符串讀取數據 /// </summary> public CardsGameData(string[] cardColsData,string cardPileData) { cardCols = new List<List<Card>>(); cardPile = new List<Card>(); //讀取每一列的數據 for (int i = 0; i < cardColsData.Length; i++) { List<Card> cardCol = new List<Card>(); string[] colData = cardColsData[i].Split(' '); for (int index = 0; index < colData.Length; index++) { Card card = new Card(colData[index], index == colData.Length - 1); cardCol.Add(card); } cardCols.Add(cardCol); } //讀取牌堆數據 string[] pileData = cardPileData.Split(' '); for (int index = 0; index < pileData.Length; index++) { Card card = new Card(pileData[index]); cardPile.Add(card); } } /// <summary> /// 純隨機紙牌 /// </summary> public void CreateRandomCards() { //生成52張牌 List<Card> AllCards = new List<Card>(); for (int i = 0; i < cardTypeNum; i++) { for (int j = 1; j <= 13; j++) { Card card = new Card((CardType)i, j); AllCards.Add(card); } } KnuthDurstenfeld(AllCards); //純隨機洗牌 //把牌放進列表和牌堆 int temp = 0; cardCols = new List<List<Card>>(); cardPile = new List<Card>(); for (int col = 0; col < 7; col++) { List<Card> cardColList = new List<Card>(); while (cardColList.Count < col + 1) { cardColList.Add(AllCards[temp++]); } cardCols.Add(cardColList); } for (int index = 0; index < 24; index++) { cardPile.Add(AllCards[temp++]); } } /// <summary> /// Knuth-Durstenfeld Shuffle打亂算法 /// </summary> public void KnuthDurstenfeld<T>(List<T> targetList) { Random random = new Random(); for (int i = targetList.Count - 1; i > 0; i--) { int exchange = random.Next(0, i + 1); T temp = targetList[i]; targetList[i] = targetList[exchange]; targetList[exchange] = temp; } } #endregion #region 輸出 /// <summary> /// 輸出牌局信息 /// </summary> /// <returns></returns> public string PrintGameData() { string gameStr = ""; //輸出每一列的牌 for (int col = 0; col < 7; col++) { for (int i = 0; i < cardCols[col].Count; i++) { gameStr += cardCols[col][i].PrintCard(); if (i != cardCols[col].Count - 1) gameStr += " "; } gameStr += "\r\n"; } //輸出牌堆的牌 for (int i = 0; i < cardPile.Count; i++) { gameStr += cardPile[i].PrintCard(); if (i != cardPile.Count - 1) gameStr += " "; } return gameStr; } #endregion }
現在隨機生成按鈕的相關功能已經完成,來看看效果
四、紙牌移動問題
1,移動操作類CardOperate設計
紙牌的移動分為6種:牌堆翻牌(右上),從牌堆拿牌(右上拿到下面),從牌堆直接收集(右上拿到左上),移動牌(下面一列拿到另一列),從列表收集牌(下面拿到左上),從收集區拿回列表(左上拿到下面)。
具體代碼如下:
enum OperateType { Flop = 0, //翻牌 GetFormPile = 1, //從牌堆拿牌 DirectionCollect=2, //從牌堆直接收集牌 Move = 3, //移動牌 Collect = 4, //從列表收集牌 Back = 5, //從收集區把牌拿回列表 Unknown = 6, } class CardOperate { public OperateType operateType; public int OriIndex; //原來挪動的下標 public int CurIndex; //挪動之后的下標 public CardOperate() { } public CardOperate(OperateType operateType, int OriIndex, int CurIndex) { this.operateType = operateType; this.OriIndex = OriIndex; this.CurIndex = CurIndex; } public string PrintOperate() { string ope = ""; switch(operateType) { case OperateType.Flop: { ope = "F"; break; } case OperateType.GetFormPile: { ope = "G" + CurIndex; break; } case OperateType.DirectionCollect: { ope = "D" + CurIndex; break; } case OperateType.Move: { ope = "M" + OriIndex + "_" + CurIndex; break; } case OperateType.Collect: { ope = "C" + OriIndex + "_" + CurIndex; break; } case OperateType.Back: { ope = "B" + OriIndex + "_" + CurIndex; break; } } return ope; } }
輸出格式和牌類似,前面是操作首字母,后面數字是挪之前的下標,挪之后的下標(兩個數字用_分隔,可以沒有數字)
例如:翻牌——F,從牌堆直接收集紅桃A——D0(之前規定了紅桃收集到第0列),從第2列移動到第4列——M2_4
2,牌局類CardsGameData的移動函數
再回到之前的牌局類,需要兩大功能:檢查當前局面能夠進行哪些操作;對當前局面進行一步具體操作。
我們規定:翻牌之后必須操作右上角的牌堆。這樣可以避免很多冗余的分支(比如說我翻一下牌堆,然后又回到下面移動牌列。這樣其實和先移動牌列,再翻牌的分支重復)。雖然說AStar算法會對相同局面重新規划路線,但是這無疑浪費了大量的計算(大約70%)。
之前使用深度優先搜索時,搜索樹分支的順序會影響搜索,所以在獲取當前局面所有操作時,有先后順序。這個順序在AStar算法中應該是不會產生影響的。
具體代碼如下:
#region 游戲操作 /// <summary> /// 初始化游戲 /// </summary> public void InitGame() { CollectAreaTop = new List<int>{ 0, 0, 0, 0 }; curPilePos = 0; curPileTop = 0; } /// <summary> /// 獲取當前局面的所有操作 /// </summary> /// <param name="OnlyPileOperates">只允許牌堆操作</param> /// <returns></returns> public List<CardOperate> GetAllOperates(bool OnlyPileOperates = false) { List<CardOperate> AllOperates = new List<CardOperate>(); //獲取當前牌堆頂的牌 Card curPileCard = null; if (curPileTop > 0 && curPileTop <= cardPile.Count) { curPileCard = cardPile[curPileTop - 1]; } //1.優先把牌收集上去——從牌堆直接收集 if (curPileCard != null) { if (CollectAreaTop[(int)curPileCard.cardType] == curPileCard.num - 1) { CardOperate curOperate = new CardOperate(OperateType.DirectionCollect, 0, (int)curPileCard.cardType); AllOperates.Add(curOperate); } } if(!OnlyPileOperates) { //2.優先把牌收集上去——從列表收集 for (int col = 0; col < cardCols.Count; col++) { if (cardCols[col].Count > 0) { Card endCard = cardCols[col][cardCols[col].Count - 1]; //最底下一張牌 if (CollectAreaTop[(int)endCard.cardType] == endCard.num - 1) { CardOperate curOperate = new CardOperate(OperateType.Collect, col, (int)endCard.cardType); AllOperates.Add(curOperate); } } } //3.移動牌 for (int col = 0; col < cardCols.Count; col++) { for (int index = cardCols[col].Count - 1; index >= 0; index--) { //判斷這張牌是否能能帶着下面的牌一起動 bool canMove = false; //暫時讓最上面的K不動 if(index == 0 && cardCols[col][index].num == 13) { canMove = false; } else if (index == cardCols[col].Count - 1) { canMove = true; //最后一張牌肯定能動 } else { if (!cardCols[col][index].canMove) { canMove = false; //還沒翻開的牌 } else { canMove = cardCols[col][index + 1].IsOneGroup(cardCols[col][index]); } } //看看可移動的牌能不能移到其他地方 if (canMove) { for (int otherCol = 0; otherCol < cardCols.Count; otherCol++) { if (otherCol == col) { continue; } if (CheckMove(cardCols[col][index], otherCol)) { CardOperate curOperate = new CardOperate(OperateType.Move, col, otherCol); AllOperates.Add(curOperate); } } } else { break; //一張牌不能動,上面肯定也不能動 } } } } //4.從牌堆拿牌 if (curPileCard != null) { for (int col = 0; col < cardCols.Count; col++) { if (CheckMove(curPileCard, col)) { CardOperate curOperate = new CardOperate(OperateType.GetFormPile, 0, col); AllOperates.Add(curOperate); } } } //5.翻牌 if (cardPile.Count > 0) { CardOperate curOperate = new CardOperate(OperateType.Flop, 0, 0); AllOperates.Add(curOperate); } if (!OnlyPileOperates) { //6.從收集槽拿回來 for (int i = 0; i < CollectAreaTop.Count; i++) { if (CollectAreaTop[i] == 0) { continue; //牌堆已經空了 } CardType cur_CardType = (CardType)i; for (int col = 0; col < cardCols.Count; col++) { if (cardCols[col].Count == 0) { continue; //一般情況下,已經收集的K不會拿到空槽 } Card endCard = cardCols[col][cardCols[col].Count - 1]; //某列的最后一張牌 if (endCard.IsDifferentColor(cur_CardType) && (CollectAreaTop[i] + 1 == endCard.num)) { CardOperate curOperate = new CardOperate(OperateType.Back, i, col); AllOperates.Add(curOperate); } } } } return AllOperates; } /// <summary> /// 進行一步操作 /// </summary> public bool DoOperate(CardOperate cardOperate) { switch(cardOperate.operateType) { //翻牌 case OperateType.Flop: { curPilePos += 3; if(curPilePos - cardPile.Count >= 3) { curPilePos = 0; //移除空元素 cardPile.RemoveAll(card => card == null); } if(curPilePos > cardPile.Count) { curPileTop = cardPile.Count; //結尾不足3張,牌堆頂是最后一張 } else { curPileTop = curPilePos; //其他情況牌堆頂和翻牌位置保持一致 } break; } //從牌堆拿牌 case OperateType.GetFormPile: { Card curPileCard = GetCardFromPile(); //從牌堆取一張牌 if(curPileCard == null) { return false; } //把牌從牌堆挪下來 if (CheckMove(curPileCard, cardOperate.CurIndex)) { cardCols[cardOperate.CurIndex].Add(curPileCard); } else { return false; } break; } //直接從牌堆收集 case OperateType.DirectionCollect: { Card curPileCard = GetCardFromPile(); //從牌堆取一張牌 if (curPileCard == null) { return false; } //挪到收集區,收集區只存最大數字,+1即可 if ((int)curPileCard.cardType == cardOperate.CurIndex && CollectAreaTop[cardOperate.CurIndex] + 1 == curPileCard.num) { CollectAreaTop[cardOperate.CurIndex]++; } else { return false; } break; } //在兩列之間移動牌 case OperateType.Move: { bool checkMoveSuccess = false; int index = cardCols[cardOperate.OriIndex].Count - 1; for (; index >= 0; index--) { //判斷這張牌是否能能帶着下面的牌一起動 bool canMove = false; if (index == cardCols[cardOperate.OriIndex].Count - 1) { canMove = true; //最后一張牌肯定能動 } else { if (!cardCols[cardOperate.OriIndex][index].canMove) { canMove = false; //還沒翻開的牌 } else { canMove = cardCols[cardOperate.OriIndex][index + 1].IsOneGroup(cardCols[cardOperate.OriIndex][index]); } } //看看可移動的牌能不能移到目標列 if (canMove) { checkMoveSuccess = CheckMove(cardCols[cardOperate.OriIndex][index], cardOperate.CurIndex); if (checkMoveSuccess) { break; } } else { break; //一張牌不能動,上面肯定也不能動 } } //取出之前一列的需要移動的一組牌 if (checkMoveSuccess) { //把牌加入另一列 for (int i = index; i < cardCols[cardOperate.OriIndex].Count; i++) { cardCols[cardOperate.CurIndex].Add(cardCols[cardOperate.OriIndex][i]); } //移除之前一列的牌 cardCols[cardOperate.OriIndex].RemoveRange(index, cardCols[cardOperate.OriIndex].Count - index); } else { return false; } //翻開上一張牌 if(cardCols[cardOperate.OriIndex].Count > 0) { cardCols[cardOperate.OriIndex][cardCols[cardOperate.OriIndex].Count - 1].canMove = true; } break; } //從列表收集牌 case OperateType.Collect: { //取出之前一列的最后一張牌 if (cardCols[cardOperate.OriIndex].Count == 0) { return false; } Card endCard = cardCols[cardOperate.OriIndex][cardCols[cardOperate.OriIndex].Count - 1]; cardCols[cardOperate.OriIndex].Remove(endCard); //翻開上一張牌 if (cardCols[cardOperate.OriIndex].Count > 0) { cardCols[cardOperate.OriIndex][cardCols[cardOperate.OriIndex].Count - 1].canMove = true; } //挪到收集區,收集區只存最大數字,+1即可 if ((int)endCard.cardType == cardOperate.CurIndex && CollectAreaTop[cardOperate.CurIndex] + 1 == endCard.num) { CollectAreaTop[cardOperate.CurIndex]++; } else { return false; } break; } //從收集區挪回列表 case OperateType.Back: { Card backCard = new Card((CardType)cardOperate.OriIndex, CollectAreaTop[cardOperate.OriIndex]); //把牌挪回目標列 if (CheckMove(backCard, cardOperate.CurIndex)) { cardCols[cardOperate.CurIndex].Add(backCard); } else { return false; } break; } } return true; } /// <summary> /// 從牌堆取一張牌 /// </summary> public Card GetCardFromPile() { Card curPileCard = null; if (curPileTop > 0 && curPileTop <= cardPile.Count) { curPileCard = cardPile[curPileTop - 1]; cardPile[curPileTop - 1] = null; } //尋找牌堆的上一張牌 int index = curPileTop - 1; for (; index > 0; index--) { if (cardPile[index - 1] != null) { break; } } curPileTop = index; return curPileCard; } /// <summary> /// 檢查某張牌是否能挪到另一列 /// </summary> /// <returns></returns> public bool CheckMove(Card card,int targetColIndex) { bool canMove = false; if (cardCols[targetColIndex].Count == 0) { //空槽只能挪K canMove = card.num == 13; } else { //校驗上下兩張牌是否是同一種顏色 canMove = card.IsOneGroup(cardCols[targetColIndex][cardCols[targetColIndex].Count - 1]); } return canMove; } #endregion
五、牌局類剩余問題
牌局類還剩下一些小問題:
比較兩個局面是否相同:按照收集區、牌堆、紙牌列的順序依次比較。
是否過關:查看收集區每一列是否都收集到了13
計算局面分后面再一起說,先把這塊代碼貼上:
#region 比較 /// <summary> /// 比較兩個局面是否相同 /// </summary> public bool EqualsTo(CardsGameData other) { //比較收集區,比較翻牌位置 for (int i = 0; i < CollectAreaTop.Count; i++) { if (CollectAreaTop[i] != other.CollectAreaTop[i]) return false; } if (curPilePos != other.curPilePos) return false; if (curPileTop != other.curPileTop) return false; //比較紙牌堆 if (cardPile.Count != other.cardPile.Count) return false; for (int i = 0; i < cardPile.Count; i++) { if (!Card.IsTwoCardEqual(cardPile[i], other.cardPile[i])) return false; } //比較紙牌列 for (int col = 0; col < cardCols.Count; col++) { if (cardCols[col].Count != other.cardCols[col].Count) return false; for (int i = 0; i < cardCols[col].Count; i++) { if (!Card.IsTwoCardEqual(cardCols[col][i], other.cardCols[col][i])) return false; } } return true; } /// <summary> /// 檢查是否過關 /// </summary> public bool CheckSuccess() { for (int i = 0; i < CollectAreaTop.Count; i++) { if (CollectAreaTop[i] != 13) return false; } return true; } /// <summary> /// 計算局面分 /// </summary> public int GetScore() { int score = 0; //收集區每收集一張+14分,四張全部收集+100分 int min = 100; for (int i = 0; i < CollectAreaTop.Count; i++) { if(CollectAreaTop[i]<min) { min = CollectAreaTop[i]; } } score += 100 * min; for (int i = 0; i < CollectAreaTop.Count; i++) { //score += 15 * (CollectAreaTop[i] - min); //每超出1級-4分,避免某一種花色收集太多 int addScore = 14; for (int collectNum = min + 1; collectNum <= CollectAreaTop[i]; collectNum++) { score += addScore; if (addScore > 4) { addScore -= 4; } } } //牌列有序+8分,否則-2分 for (int col = 0; col < cardCols.Count; col++) { for (int i = cardCols[col].Count - 1; i > 0; i--) { if (cardCols[col][i - 1].canMove && cardCols[col][i].IsOneGroup(cardCols[col][i - 1])) { score += 8; } else { score -= 2; } } if (cardCols[col].Count > 0) { if (cardCols[col][0].canMove && cardCols[col][0].num == 13) { score += 8; } else { score -= 2; } } } return score; } #endregion
六、節點評分和搜索節點類AStarSearchNode設計
1,節點評分問題
AStar算法的節點評分是F=H+G,H是當前節點距離終點的期望,也就是局面分。G是步數消耗,即從初始點走過來消耗的代價。每次展開時,優先展開評分最高的節點。注意評分增減要平衡,不能增長過快,否則會一條路走到黑,就和深度優先差不多了。
最初,我設計的評分規則是:
H:
四張牌全部收集:+100分
收集了單獨一張牌:+15分
牌列有序(能帶着下面一起挪):每張+8分
牌列無序:每張-2分
G:
走1步:-4分
把牌從收集區挪回去:-40分
后來發現有2個問題,一是K不會優先挪到空列,二是只要一有機會就往收集區挪,常常導致收集區某個花色堆得特別高,然后解不出來。於是優化出了二代評分:
H:
四張牌全部收集到n:+100n分
收集了單獨一張牌x:+14-4(x-n-1)分,最低為2分,不會出現負分
牌列有序(能帶着下面一起挪):每張+8分
牌列無序:每張-2分
每列最頂上一張是K(且已翻開)+8分,否則-2分
G:
走1步:-4分
把牌從收集區挪回去:-40分
2,節點類設計
AStar搜索時,經常會更換父節點,此時需要刷新當前節點所有子節點的得分,直接遞歸深度優先遍歷即可。完整代碼如下:
class AStarSearchNode { public CardsGameData gameData; //當前局面 public CardOperate curOperate; //經過何種操作到達當前局面 public List<AStarSearchNode> childNodes; //操作一步可以達到的子節點 public int depth; //當前節點的搜索深度 public int score_H; //當前游戲的局面分 public int score_G; //得分的步數修正 public int score_Final; //最終得分 public AStarSearchNode fatherNode; //父節點 public bool isOpen; //當前節點是否已經展開 public AStarSearchNode(CardsGameData gameData) { //構建根節點 this.gameData = gameData; depth = 0; score_H = gameData.GetScore(); //計算局面分 score_G = 0; score_Final = score_G + score_H; //最終得分 isOpen = false; } public AStarSearchNode(CardsGameData gameData,AStarSearchNode father,CardOperate curOperate) { this.gameData = gameData; this.fatherNode = father; this.curOperate = curOperate; depth = father.depth + 1; score_H = gameData.GetScore(); //計算局面分 if(curOperate.operateType == OperateType.Back) { score_G = fatherNode.score_G - 40; //挪回去-40分,不鼓勵往回挪 } else { score_G = fatherNode.score_G - 4; //每走一步-4分,減太多了展不開,減太少一條路走到黑 } score_Final = score_G + score_H; //最終得分 isOpen = false; } #region 節點展開與子節點操作 /// <summary> /// 展開節點 /// </summary> public void OpenNode() { if(isOpen) { return; //該節點已經展開 } List<CardOperate> allOperates; if (curOperate==null) { allOperates = gameData.GetAllOperates(); //根節點直接展開 } else { bool isFlop = curOperate.operateType == OperateType.Flop; allOperates = gameData.GetAllOperates(isFlop); } childNodes = new List<AStarSearchNode>(); foreach (var item in allOperates) { CardsGameData childGame = new CardsGameData(gameData); //拷貝一份 childGame.DoOperate(item); //構建子游戲局面 AStarSearchNode childNode = new AStarSearchNode(childGame, this,item); //構建子節點 childNodes.Add(childNode); } isOpen = true; } /// <summary> /// 更改父節點 /// </summary> public void ChangeFather(AStarSearchNode father, Action<AStarSearchNode> OnChangeScore) { this.fatherNode = father; RefreshScore(OnChangeScore); } /// <summary> /// 刷新得分 /// </summary> private void RefreshScore(Action<AStarSearchNode> OnChangeScore) { if (curOperate.operateType == OperateType.Back) { score_G = fatherNode.score_G - 40; //挪回去-40分,不鼓勵往回挪 } else { score_G = fatherNode.score_G - 4; //每走一步-4分,減太多了展不開,減太少一條路走到黑 } score_Final = score_G + score_H; //最終得分 if(isOpen) { foreach (var item in childNodes) { item.RefreshScore(OnChangeScore); } } else { OnChangeScore?.Invoke(this); //未開啟的節點需要調整在開啟列表的順序 } } /// <summary> /// 移除子節點 /// </summary> public void RemoveChild(AStarSearchNode child) { childNodes.Remove(child); } /// <summary> /// 移除子節點 /// </summary> public void RemoveChild(List<AStarSearchNode> childs) { foreach (var item in childs) { RemoveChild(item); } } #endregion /// <summary> /// 獲取節點數量 /// </summary> public int GetNodeNum() { int num = 1; if(isOpen) { foreach (var item in childNodes) { num += item.GetNodeNum(); } } return num; } }
七,AStar算法設計
1,開啟列表問題
開啟列表使用鏈表LinkedList<AStarSearchNode>存儲,主要是為了方便插入和刪除。
開啟列表降序排列,每次取出第一個節點進行展開。
每次新增局面時都需要排序,自然聯想到插入排序。鏈表的插入排序很簡單,找到要插入的節點之間往后面插入就行了。
2,相同局面問題
從動輒幾萬,幾十萬的搜索樹中排查是否有相同局面是非常困難的,但是局面相同的前提是局面分H相同,所以把相同局面分的節點放在一起,使用Dictionary<int, List<AStarSearchNode>>存儲,方便比較。
當遇到相同局面時,保留深度較淺的局面。當加入新節點時,如果有重復節點,先看哪個深度淺。如果重復節點深度淺,則直接把剛加入的節點移除就完事了。如果當前節點深度淺,不能直接移除重復的節點,因為重復節點很可能展開過,那樣展開的搜索樹就沒了,所以必須把重復節點整個挪過來。這也就是AStar算法中的重新規划路線。
我們規定,挪動節點時,未開啟節點在2個及以下的,在開啟列表刪除,重新插入排序。未開啟節點大於2個的,對整個開啟列表重新排序。可以調用鏈表LinkedList自帶的OrderByDescending函數進行降序排序。(這里的2其實影響不大,設置成1,3,5差距都不太大)
3,無解的情況
除了那種開局就挪不動的,其實很難算到無解,因為搜索樹實在太大了。大概算個半小時一小時還算不出來的,多半就無解了。
具體代碼如下:
class AStarGameAnalyze { public Form_Main mainForm; //主界面索引 private CardsGameData OriGameData; //原始游戲數據 //求解信息 private Thread thread = null; public AStarSearchNode rootNode; //根節點 public LinkedList<AStarSearchNode> openList; //開啟列表 public Dictionary<int, List<AStarSearchNode>> ScoreNodeDict; //根據局面分查找節點的表 DateTime startTime; /// 停止當前計算 /// </summary> public void StopAnalyze() { if (thread != null && thread.IsAlive) { thread.Abort(); //Framework框架直接殺線程即可,無需掛后台 } } #region 排序和查找 /// <summary> /// 打開列表插入排序,得分大的在前面,方便取出和移除 /// </summary> public void AddToOpenList(AStarSearchNode curNode) { LinkedListNode<AStarSearchNode> tempNode = openList.First; while(tempNode!=null) { if (tempNode.Value.score_Final >= curNode.score_Final) { tempNode = tempNode.Next; } else { openList.AddBefore(tempNode, curNode); //往前加 return; } } //沒加進去,說明新節點得分最小,放在最后 openList.AddLast(curNode); } /// <summary> /// 節點得分更新后刷新位置 /// </summary> public void RefreshNodePosInOpenList(AStarSearchNode curNode) { openList.Remove(curNode); AddToOpenList(curNode); } /// <summary> /// 節點得分更新后刷新位置 /// </summary> public void RefreshNodePosInOpenList(List<AStarSearchNode> curNodes) { if (curNodes.Count == 0) return; foreach (var item in curNodes) { openList.Remove(item); } foreach (var item in curNodes) { AddToOpenList(item); } } /// <summary> /// 將節點插入得分列表 /// </summary> public void AddNodeToScoreDict(AStarSearchNode curNode) { //獲取得分表 List<AStarSearchNode> scoreList; ScoreNodeDict.TryGetValue(curNode.score_H, out scoreList); if(scoreList==null) { //沒有相應得分的表,創建一個新的 scoreList = new List<AStarSearchNode>(); scoreList.Add(curNode); ScoreNodeDict.Add(curNode.score_H, scoreList); } else { //加入已有的得分表 scoreList.Add(curNode); } } /// <summary> /// 查找重復節點 /// </summary> public AStarSearchNode FindRepeatNodeFromScoreDict(AStarSearchNode curNode) { List<AStarSearchNode> scoreList; ScoreNodeDict.TryGetValue(curNode.score_H, out scoreList); if (scoreList == null) { //沒有相應得分的表,不存在重復的 return null; } else { //遍歷得分表,查看有無重復元素 foreach (var item in scoreList) { if(item.gameData.EqualsTo(curNode.gameData)) { return item; } } return null; } } #endregion #region 求解相關 /// <summary> /// 求解 /// </summary> public void SolveGame(Form_Main mainForm, CardsGameData gameData) { this.mainForm = mainForm; this.OriGameData = gameData; mainForm.ClearConcole(); mainForm.AddConcole("開始A*求解"); startTime = DateTime.Now; //TrySolveGame(); thread = new Thread(TrySolveGame); thread.Start(); } /// <summary> /// 嘗試求解 /// </summary> public void TrySolveGame() { OriGameData.InitGame(); rootNode = new AStarSearchNode(OriGameData); rootNode.OpenNode(); openList = new LinkedList<AStarSearchNode>(); //開啟列表 ScoreNodeDict = new Dictionary<int, List<AStarSearchNode>>(); //得分列表,用於查找重復節點 AddNodeToScoreDict(rootNode); foreach (var item in rootNode.childNodes) { AddToOpenList(item); AddNodeToScoreDict(item); } AStarSearchNode resultNode = null; AStarSearchNode depthNode = rootNode; int step = 0; int depth = 0; int repeatNodeNum = 0; //AStar搜索 while (openList.Count > 0 && resultNode == null) { step++; if (step % 1000 == 0) { mainForm.AddConcole("已進行" + step + "次計算,當前搜索樹大小:" + rootNode.GetNodeNum() + ",最大搜索深度:" + depth + ",重復節點數量:" + repeatNodeNum); } if (step % 10000 == 0) { mainForm.AddConcole("最深處節點路徑:"); mainForm.AddConcole(PrintNodePath(depthNode)); } //已經插入排序,最高的節點是第一個 AStarSearchNode maxNode = openList.First.Value; //展開對應的節點 //openList.Remove(maxNode); //移除已經展開的節點 openList.RemoveFirst(); //移除已經展開的節點 maxNode.OpenNode(); if(maxNode.depth > depth) { depth = maxNode.depth; depthNode = maxNode; } List<AStarSearchNode> removeChildList = new List<AStarSearchNode>(); //需要移除的子節點 for (int i = 0; i < maxNode.childNodes.Count; i++) { //檢查子節點是否重復 AStarSearchNode repeatNode = FindRepeatNodeFromScoreDict(maxNode.childNodes[i]); if (repeatNode != null) { repeatNodeNum++; if (repeatNode.depth <= maxNode.childNodes[i].depth) { //重復節點深度更淺,移除當前節點 removeChildList.Add(maxNode.childNodes[i]); } else { //當前節點深度更淺,把重復節點整體挪過來 repeatNode.fatherNode.RemoveChild(repeatNode); repeatNode.curOperate = maxNode.childNodes[i].curOperate; maxNode.childNodes[i] = repeatNode; //repeatNode.ChangeFather(maxNode, curNode => RefreshNodePosInOpenList(curNode)); List<AStarSearchNode> refreshNodes = new List<AStarSearchNode>(); repeatNode.ChangeFather(maxNode, curNode => refreshNodes.Add(curNode)); openList.OrderByDescending(item => item.score_Final); if (refreshNodes.Count <= 2) { RefreshNodePosInOpenList(refreshNodes); } else { openList.OrderByDescending(item => item.score_Final); } } } else { AddToOpenList(maxNode.childNodes[i]); //將子節點加入開啟列表 AddNodeToScoreDict(maxNode.childNodes[i]); //將子節點加入得分表,便於后續查找重復節點 } //檢查子節點是否有完成的 if (maxNode.childNodes[i].gameData.CheckSuccess()) { resultNode = maxNode.childNodes[i]; } } maxNode.RemoveChild(removeChildList); //移除重復的子節點 } //統計計算時間 DateTime curTime = DateTime.Now; var deltaTime = curTime - startTime; mainForm.AddConcole("總時間" + deltaTime.TotalSeconds + "s"); //判斷是否無解 if (resultNode == null) { mainForm.AddConcole("無解"); } else { mainForm.AddConcole("已找到一個解,步數=" + resultNode.depth + ",當前解如下:"); mainForm.AddConcole(PrintNodePath(resultNode)); } } #endregion #region 輸出 /// <summary> /// 輸出某個節點的路徑 /// </summary> public string PrintNodePath(AStarSearchNode targetNode) { string res = ""; Stack<CardOperate> pathStack = new Stack<CardOperate>(); //路徑棧 AStarSearchNode curNode = targetNode; //從終點開始,將路徑倒着入棧 while (curNode.fatherNode != null) { pathStack.Push(curNode.curOperate); curNode = curNode.fatherNode; } //起點的路徑在棧頂了,輸出路徑 while(pathStack.Count>0) { CardOperate curOperate = pathStack.Pop(); res += curOperate.PrintOperate() + " "; } return res; } #endregion }
最后再補充一點,關於停止線程的,Abort函數只能用在Framework里,如果是.net Core,請把線程扔到后台,這個問題之前在數織游戲(Nonogram)求解的帖子中說過。
來看一下效果:
八、優化思路
1,節點評分那塊還可以優化,我最初寫的評分算法很爛,算幾個小時都算不出一局。后來經過多次優化才有了現在的評分算法,不過仍然有很大的優化空間。如果能把局面分H打得更散,不僅僅是走的分支不同,查找相同局面的效率也會提升。
2,開啟列表可以不要鏈表,改用二叉堆,在插入和刪除上都可以提升效率