基于AStar算法的纸牌接龙求解工具(C#实现)
一、游戏规则介绍
纸牌接龙是一个很经典的游戏了,相信很多人小时候都玩过。
规则如下:
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,开启列表可以不要链表,改用二叉堆,在插入和删除上都可以提升效率
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 我与微信审核的“相爱相杀”看个人小程序副业