一、游戏规则介绍
纸牌接龙是一个很经典的游戏了,相信很多人小时候都玩过。


规则如下:
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,开启列表可以不要链表,改用二叉堆,在插入和删除上都可以提升效率
















