基於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,開啟列表可以不要鏈表,改用二叉堆,在插入和刪除上都可以提升效率

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM