拼圖遊戲和它的AI演算法

囧叔發表於2017-12-22

寫了個拼圖遊戲,探討一下相關的AI演算法。拼圖遊戲的復原問題也叫做N數碼問題。

  • 拼圖遊戲
  • N數碼問題
  • 廣度優先搜尋
  • 雙向廣度優先搜尋
  • A*搜尋

遊戲設定

實現一個拼圖遊戲,使它具備以下功能:

  1. 自由選取喜歡的圖片來遊戲
  2. 自由選定空格位置
  3. 空格鄰近的方塊可移動,其它方塊不允許移動
  4. 能識別圖片是否復原完成,遊戲勝利時給出反饋
  5. 一鍵洗牌,打亂圖片方塊
  6. 支援重新開始遊戲
  7. 難度分級:高、中、低
  8. 具備人工智慧,自動完成拼圖復原
  9. 實現幾種人工智慧演算法:廣度優先搜尋、雙向廣度優先搜尋、A*搜尋
  10. 儲存遊戲進度
  11. 讀取遊戲進度
    Puzzle Game.png

自動完成拼圖復原

先看看完成後的效果。點自動按鈕後,遊戲將會把當前的拼圖一步一步移動直到復原圖片。

自動復原.gif

圖片與方塊

圖片的選取可通過拍照、從相簿選,或者使用內建預設圖片。 由於遊戲是在正方形區域內進行的,所以若想有最好的遊戲效果,我們需要一張裁剪成正方形的圖片。

擷取正方形區域.png

選好圖片後,需要把圖片切割成n x n塊。這裡每一個方塊PuzzlePiece都是一個UIButton。 由於圖片是會被打散打亂的,所以每個方塊應該記住它自己在原圖上的初始位置,這裡給方塊新增一個屬性ID,用於儲存。

@interface PuzzlePiece : UIButton

/// 本方塊在原圖上的位置,從0開始編號
@property (nonatomic, assign) NSInteger ID;

/// 建立例項
+ (instancetype)pieceWithID:(NSInteger)ID image:(UIImage *)image;

@end
複製程式碼

難度選擇

切割後的圖片塊組成了一個n x n矩陣,亦即n階方陣。而想要改變遊戲難度,我們只需要改變方陣的階數即可。 設計三檔難度,從低到高分別對應3 x 34 x 45 x 5的方陣。

難度選擇.gif

假如我們把遊戲中某個時刻的方塊排列順序稱為一個狀態,那麼當階數為n時,遊戲的總狀態數就是的階乘。 在不同難度下進行遊戲將會有非常大的差異,無論是手動遊戲還是AI進行遊戲。

  • 在低難度下,拼圖共有(3*3)! = 362880個狀態,並不多,即便是最慢的廣搜演算法也可以在短時間內搜出復原路徑。

3階方陣的搜尋空間.png

  • 在中難度下,拼圖變成了4階方陣,拼圖狀態數飆升到(4*4)! = 20922789888000,二十萬億。廣搜演算法已基本不能搜出結果,直到爆記憶體。

廣搜演算法佔用的巨量記憶體.gif

  • 在高難度下,拼圖變成了5階方陣,狀態數是個天文數字(5*5)! = 1.551121004333098e25,10的25次方。此時無論是廣搜亦或是雙向廣搜都已無能為力,而A*尚可一戰。

高難度下的5階方陣.png

方塊移動

在選取完圖片後,拼圖是完整無缺的,此時讓第一個被觸擊的方塊成為空格。 從第二次觸擊開始,將會對所觸擊的方塊進行移動,但只允許空格附近的方塊發生移動。 每一次移動方塊,實質上是讓方塊的位置與空格的位置進行交換。在這裡思維需要轉個小彎,空格並不空,它也是一個物件,只不過表示出來是一塊空白而已。那麼我們移動了方塊,是否可以反過來想,其實是移動了空格?答案是肯定的,並且思維這樣轉過來後,更方便程式碼實現。

方塊移動.gif

打亂方塊順序

這裡為了讓打亂順序後的拼圖有解,採用隨機移動一定步數的方法來實現洗牌。 對於n階方陣,可設計隨機的步數為:n * n * 10。在實際測試當中,這個隨機移動的步數已足夠讓拼圖完全亂序,即使讓隨機的步數再加大10倍,其復原所需的移動步數也變化不大。復原步數與方陣的階數有關,無論打亂多少次,復原步數都是趨於一個穩定的範圍。

打亂方塊順序.gif

隨機移動一定步數.png

拼圖狀態

我們需要定義一個類來表示拼圖在某個時刻的狀態。 一個狀態應持有以下幾個屬性:

  • 矩陣階數
  • 方塊陣列,以陣列的順序來表示本狀態下方塊的排列順序
  • 空格所在的位置,其值指向方塊陣列中顯示成空白的那一個方塊

同時它應能提供操作方塊的方法,以演進遊戲狀態。

  • 判斷空格是否能移動到某個位置
  • 把空格移動到某個位置
  • 移除所有方塊
  • 打亂所有方塊,變成一個隨機狀態
  • 與另一個狀態物件進行比較,判斷是否狀態等同
/// 表示遊戲過程中,某一個時刻,所有方塊的排列狀態
@interface PuzzleStatus : NSObject <JXPathSearcherStatus, JXAStarSearcherStatus>

/// 矩陣階數
@property (nonatomic, assign) NSInteger matrixOrder;

/// 方塊陣列,按從上到下,從左到右,順序排列
@property (nonatomic, strong) NSMutableArray<PuzzlePiece *> *pieceArray;

/// 空格位置,無空格時為-1
@property (nonatomic, assign) NSInteger emptyIndex;

/// 建立例項,matrixOrder至少為3,image非空
+ (instancetype)statusWithMatrixOrder:(NSInteger)matrixOrder image:(UIImage *)image;

/// 複製本例項
- (instancetype)copyStatus;

/// 判斷是否與另一個狀態相同
- (BOOL)equalWithStatus:(PuzzleStatus *)status;

/// 打亂,傳入隨機移動的步數
- (void)shuffleCount:(NSInteger)count;

/// 移除所有方塊
- (void)removeAllPieces;

/// 空格是否能移動到某個位置
- (BOOL)canMoveToIndex:(NSInteger)index;

/// 把空格移動到某個位置
- (void)moveToIndex:(NSInteger)index;

@end
複製程式碼

使遊戲具備人工智慧(Artificial Intelligence, AI)

我們把拼圖在某個時刻的方塊排列稱為一個狀態,那麼一旦發生方塊移動,就會生成一個新的狀態。 對於每個狀態來說,它都能夠通過改變空格的位置而衍生出另一個狀態,而衍生出的狀態又能夠衍生出另一些狀態。這種行為非常像一棵樹的生成,當然這裡的樹指的是資料結構上的樹結構。

拼圖狀態樹.png

推演移動路徑的過程,就是根據當前狀態不斷衍生狀態,然後判斷新狀態是否為我們的目標狀態(拼圖完全復原時的狀態)。如果找到了目標,就可以原路返回,依次找出目標所經過的所有狀態。 由此,狀態樹中的每一個結點都需要提供以下屬性和方法:

  • 父結點引用。要實現從目標狀態逆向找回所有經過的狀態,需要讓每一個狀態都持有它上一狀態的引用,即持有它的父結點引用。
  • 結點的唯一標識。用於演算法過程中識別狀態等同,以及雜湊策略去重。
  • 子結點的生成方法。用於衍生出新的結點,演進搜尋。
/// 狀態協議
@protocol JXPathSearcherStatus <NSObject>

/// 父狀態
@property (nonatomic, strong) id<JXPathSearcherStatus> parentStatus;

/// 此狀態的唯一標識
- (NSString *)statusIdentifier;

/// 取所有鄰近狀態(子狀態),排除父狀態。每一個狀態都需要給parentStatus賦值。
- (NSMutableArray<id<JXPathSearcherStatus>> *)childStatus;

@end
複製程式碼

對於一個路徑搜尋演算法來說,它應該知道開始於哪裡,和結束於哪裡。 再有,作為一個通用的演算法,不僅限於拼圖遊戲的話,它還需要演算法使用者傳入一個比較器,用於判斷兩個搜尋狀態是否等同,因為演算法並不清楚它所搜尋的是什麼東西,也就不知道如何確定任意兩個狀態是否一樣的。 給路徑搜尋演算法作如下屬性和方法定義:

/// 比較器定義
typedef BOOL(^JXPathSearcherEqualComparator)(id<JXPathSearcherStatus> status1, id<JXPathSearcherStatus> status2);

/// 路徑搜尋
@interface JXPathSearcher : NSObject

/// 開始狀態
@property (nonatomic, strong) id<JXPathSearcherStatus> startStatus;

/// 目標狀態
@property (nonatomic, strong) id<JXPathSearcherStatus> targetStatus;

/// 比較器
@property (nonatomic, strong) JXPathSearcherEqualComparator equalComparator;

/// 開始搜尋,返回搜尋結果。無法搜尋時返回nil
- (NSMutableArray *)search;

/// 構建路徑。isLast表示傳入的status是否路徑的最後一個元素
- (NSMutableArray *)constructPathWithStatus:(id<JXPathSearcherStatus>)status isLast:(BOOL)isLast;

@end
複製程式碼

關於“搜尋”兩字,在程式碼上可以理解為拿著某個狀態與目標狀態進行比較,如果這兩個狀態一致,則搜尋成功;如果不一致,則繼續取另一個狀態與目標狀態比較,如此迴圈下去直到找出與目標一致的狀態。 各演算法的區別,主要在於它們對搜尋空間內的狀態結點有不同的搜尋順序。

廣度優先搜尋(Breadth First Search, BFS)

廣度優先搜尋是一種盲目搜尋演算法,它認為所有狀態(或者說結點)都是等價的,不存在優劣之分。

自然界的廣度優先搜尋.gif

假如我們把所有需要搜尋的狀態組成一棵樹來看,廣搜就是一層搜完再搜下一層,直到找出目標結點,或搜完整棵樹為止。

  1. 我們可以使用一個先進先出(First Input First Output, FIFO)的佇列來存放待搜尋的狀態,這個佇列可以給它一個名稱叫開放佇列,也有人把它叫做開放列表(Open List)。
  2. 然後還需要把所有已搜尋過的狀態記錄下來,以確保不會對已搜尋過的狀態作重複擴充套件,注意這裡的擴充套件即為衍生出子狀態,對應於拼圖遊戲來說就是空格移動了一格。 由於每搜到一個狀態,都需要拿著這個狀態去已搜記錄中查詢是否有這個狀態存在,那麼已搜記錄要使用怎樣的儲存方式才能適應這種高頻率查詢需求呢? 假如我們使用陣列來儲存所有已搜記錄,那麼每一次查詢都需要遍歷整個陣列。當已搜記錄表的資料有10萬條時,再去搜一個新狀態,就需要做10萬次迴圈來確定新狀態是從來沒有被搜尋過的。顯然這樣做的效率是非常低的。 一種高效的方法是雜湊策略,**雜湊表(Hash Table)**能通過鍵值對映直接查詢到目標物件,免去遍歷整個儲存空間。在Cocoa框架中,已經有能滿足這種鍵值對映的資料結構--字典。這裡我沒有再去實現一個雜湊表,而是使用NSMutableDictionary來存放已搜記錄。我們可以給這個儲存空間起個名字叫關閉堆,也有人把它叫做關閉列表(Close List)。
  3. 搜尋開始時,開放佇列是空的,然後我們把起始狀態入隊,此時開放佇列有了一個待搜尋的狀態,搜尋迴圈開始。
  4. 每一次迴圈的目的,就是搜尋一個狀態。所謂搜尋,前面已經講過,可以通俗理解為就是比較。我們需要從開放佇列中取出一個狀態來,假如取出的狀態是已經比較過了的,則放棄此次迴圈,直到取出一個從來沒有比較過的狀態。
  5. 拿著取出的新狀態,與目標狀態比較,如果一致,則說明路徑已找到。為何說路徑已找到了呢?因為每一個狀態都持有一個父狀態的引用,意思是它記錄著自己是來源於哪一個狀態衍生出來的,所以每一個狀態都必然知道自己上一個狀態是誰,除了開始狀態。
  6. 找到目標狀態後,就可以構建路徑。所謂路徑,就是從開始狀態到目標狀態的搜尋過程中,經過的所有狀態連起來組成的陣列。我們可以從搜尋結束的狀態開始,把它放入陣列中,然後把這個狀態的父狀態放入陣列中,再把其祖先狀態放入陣列中,直到放入開始狀態。如何識別出開始狀態呢?當發現某個狀態是沒有父狀態的,就說明了它是開始狀態。最後演算法把構建完成的路徑作為結果返回。
  7. 在第5步中,如果發現取出的新狀態並非目標狀態,這時就需要衍生新的狀態來推進搜尋。呼叫生成子狀態的方法,把產生的子狀態入隊,依次追加到佇列尾,這些入隊的子狀態將會在以後的迴圈中被搜尋。由於佇列的FIFO特性,在迴圈進行過程中,將會優先把某個狀態的子狀態全部出列完後,再出列其子狀態的子狀態。入列和出列的兩步操作決定了演算法的搜尋順序,這裡的操作實現了廣度優先搜尋。

廣度優先搜尋:

- (NSMutableArray *)search {
    if (!self.startStatus || !self.targetStatus || !self.equalComparator) {
        return nil;
    }
    NSMutableArray *path = [NSMutableArray array];
    
    // 關閉堆,存放已搜尋過的狀態
    NSMutableDictionary *close = [NSMutableDictionary dictionary];
    // 開放佇列,存放由已搜尋過的狀態所擴充套件出來的未搜尋狀態
    NSMutableArray *open = [NSMutableArray array];
    
    [open addObject:self.startStatus];
    
    while (open.count > 0) {
        // 出列
        id status = [open firstObject];
        [open removeObjectAtIndex:0];
        
        // 排除已經搜尋過的狀態
        NSString *statusIdentifier = [status statusIdentifier];
        if (close[statusIdentifier]) {
            continue;
        }
        close[statusIdentifier] = status;
        
        // 如果找到目標狀態
        if (self.equalComparator(self.targetStatus, status)) {
            path = [self constructPathWithStatus:status isLast:YES];
            break;
        }
        
        // 否則,擴充套件出子狀態
        [open addObjectsFromArray:[status childStatus]];
    }
    NSLog(@"總共搜尋了: %@個狀態", @(close.count));
    return path;
}
複製程式碼

構建路徑:

/// 構建路徑。isLast表示傳入的status是否路徑的最後一個元素
- (NSMutableArray *)constructPathWithStatus:(id<JXPathSearcherStatus>)status isLast:(BOOL)isLast {
    NSMutableArray *path = [NSMutableArray array];
    if (!status) {
        return path;
    }
    
    do {
        if (isLast) {
            [path insertObject:status atIndex:0];
        }
        else {
            [path addObject:status];
        }
        status = [status parentStatus];
    } while (status);
    return path;
}
複製程式碼

3階方陣,廣搜平均需要搜尋10萬個狀態

雙向廣度優先搜尋(Bi-Directional Breadth First Search)

雙向廣度優先搜尋是對廣度優先搜尋的優化,但是有一個使用條件:搜尋路徑可逆。 搜尋原理 雙向廣搜是同時從開始狀態和目標狀態展開搜尋的,這樣就會產生兩棵搜尋狀態樹。我們想象一下,讓起始於開始狀態的樹從上往下生長,再讓起始於目標狀態的樹從下往上生長,同時在它們的生長空間中遍佈著一個一個的狀態結點,等待著這兩棵樹延伸去觸及。 由於任一個狀態都是唯一存在的,當兩棵搜尋樹都觸及到了某個狀態時,這兩棵樹就出現了交叉,搜尋即告結束。 讓兩棵樹從發生交叉的狀態結點各自原路返回構建路徑,然後演算法把兩條路徑拼接起來,即為結果路徑。 可用條件 對於拼圖遊戲來說,已經知道了開始狀態(某個亂序的狀態)和目標狀態(圖片復原時的狀態),而這兩個狀態其實是可以互換的,完全可以從目標復原狀態開始搜尋,反向推進,直到找出拼圖開始時的亂序狀態。所以,我們的拼圖遊戲是路徑可逆的,適合雙向廣搜。 單執行緒下的雙向廣搜 要實現雙向廣搜,並不需要真的用兩條執行緒分別從開始狀態和目標狀態對向展開搜尋,在單執行緒下也完全可以實現,實現的關鍵是於讓兩個開放佇列交替出列元素。 在每一次迴圈中,比較兩個開放佇列的長度,每一次都選擇最短的佇列進行搜尋,優先讓較小的樹生長出子結點。這樣做能夠使兩個開放佇列維持大致相同的長度,同步增長,達到均衡兩棵搜尋樹的效果。

- (NSMutableArray *)search {
    if (!self.startStatus || !self.targetStatus || !self.equalComparator) {
        return nil;
    }
    NSMutableArray *path = [NSMutableArray array];
    
    // 關閉堆,存放已搜尋過的狀態
    NSMutableDictionary *positiveClose = [NSMutableDictionary dictionary];
    NSMutableDictionary *negativeClose = [NSMutableDictionary dictionary];
    
    // 開放佇列,存放由已搜尋過的狀態所擴充套件出來的未搜尋狀態
    NSMutableArray *positiveOpen = [NSMutableArray array];
    NSMutableArray *negativeOpen = [NSMutableArray array];
    
    [positiveOpen addObject:self.startStatus];
    [negativeOpen addObject:self.targetStatus];
    
    while (positiveOpen.count > 0 || negativeOpen.count > 0) {
        // 較短的那個擴充套件佇列
        NSMutableArray *open;
        // 短佇列對應的關閉堆
        NSMutableDictionary *close;
        // 另一個關閉堆
        NSMutableDictionary *otherClose;
        // 找出短佇列
        if (positiveOpen.count && (positiveOpen.count < negativeOpen.count)) {
            open = positiveOpen;
            close = positiveClose;
            otherClose = negativeClose;
        }
        else {
            open = negativeOpen;
            close = negativeClose;
            otherClose = positiveClose;
        }
        
        // 出列
        id status = [open firstObject];
        [open removeObjectAtIndex:0];
        
        // 排除已經搜尋過的狀態
        NSString *statusIdentifier = [status statusIdentifier];
        if (close[statusIdentifier]) {
            continue;
        }
        close[statusIdentifier] = status;
        
        // 如果本狀態同時存在於另一個已檢查堆,則說明正反兩棵搜尋樹出現交叉,搜尋結束
        if (otherClose[statusIdentifier]) {
            NSMutableArray *positivePath = [self constructPathWithStatus:positiveClose[statusIdentifier] isLast:YES];
            NSMutableArray *negativePath = [self constructPathWithStatus:negativeClose[statusIdentifier] isLast:NO];
            // 拼接正反兩條路徑
            [positivePath addObjectsFromArray:negativePath];
            path = positivePath;
            break;
        }
        
        // 否則,擴充套件出子狀態
        [open addObjectsFromArray:[status childStatus]];
    }
    NSLog(@"總搜尋數量: %@", @(positiveClose.count + negativeClose.count - 1));
    return path;
}
複製程式碼

3階方陣,雙向廣搜平均需要搜尋3500個狀態

A*搜尋(A Star)

不同於盲目搜尋,A演算法是一種啟發式演算法(Heuristic Algorithm)。 上文提到,盲目搜尋對於所有要搜尋的狀態結點都是一視同仁的,因此在每次搜尋一個狀態時,盲目搜尋並不會考慮這個狀態到底是有利於趨向目標的,還是偏離目標的。 而啟發式搜尋的啟發二字,看起來是不是感覺這個演算法就變得聰明一點了呢?正是這樣,啟發式搜尋對於待搜尋的狀態會進行不同的優劣判斷,這個判斷的結果將會對演算法搜尋順序起到一種啟發作用,越優秀的狀態將會得到越高的搜尋優先順序。 我們把對於狀態優劣判斷的方法稱為啟發函式*,通過給它評定一個搜尋代價來量化啟發值。 啟發函式應針對不同的使用場景來設計,那麼在拼圖的遊戲中,如何評定某個狀態的優劣性呢?粗略的評估方法有兩種:

  1. 可以想到,某個狀態它的方塊位置放對的越多,說明它能復原目標的希望就越大,這個狀態就越優秀,優先選擇它就能減少無效的搜尋,經過它而推演到目標的代價就會小。所以可求出某個狀態所有方塊的錯位數量來作為評估值,錯位越少,狀態越優秀。
  2. 假如讓拼圖上的每個方塊都可以穿過鄰近方塊,無阻礙地移動到目標位置,那麼每個不在正確位置上的方塊它距離正確位置都會存在一個移動距離,這個非直線的距離即為曼哈頓距離(Manhattan Distance),我們把每個方塊距離其正確位置的曼哈頓距離相加起來,所求的和可以作為搜尋代價的值,值越小則可認為狀態越優秀。

其實上述兩種評定方法都只是對當前狀態距離目標狀態的代價評估,我們還忽略了一點,就是這個狀態距離搜尋開始的狀態是否已經非常遠了,亦即狀態結點的深度值。 在拼圖遊戲中,我們進行的是路徑搜尋,假如搜尋出來的一條移動路徑其需要的步數非常多,即使最終能夠把拼圖復原,那也不是我們希望的路徑。所以,路徑搜尋存在一個最優解的問題,搜尋出來的路徑所需要移動的步數越少,就越優。 A*演算法對某個狀態結點的評估,應綜合考慮這個結點距離開始結點的代價與距離目標結點的代價。總估價公式可以表示為:

f(n) = g(n) + h(n)
複製程式碼

n表示某個結點,f(n)表示對某個結點進行評價,值等於這個結點距離開始結點的已知價g(n)加上距離目標結點的估算價h(n)。 為什麼說g(n)的值是確定已知的呢?在每次生成子狀態結點時,子狀態的g值應在它父狀態的基礎上+1,以此表示距離開始狀態增加了一步,即深度加深了。所以每一個狀態的g值並不需要估算,是實實在在確定的值。 影響演算法效率的關鍵點在於h(n)的計算,採用不同的方法來計算h值將會讓演算法產生巨大的差異。

  • 當增大h值的權重,即讓h值遠超g值時,演算法偏向於快速尋找到目標狀態,而忽略路徑長度,這樣搜尋出來的結果就很難保證是最優解了,意味著可能會多繞一些彎路,通往目標狀態的步數會比較多。
  • 當減小h值的權重,降低啟發資訊量,演算法將偏向於注重已搜深度,當h(n)恆為0時,A*演算法其實已退化為廣度優先搜尋了。(這是為照應上文的方便說法。嚴謹的說法應是退化為Dijkstra演算法,在本遊戲中,廣搜可等同為Dijkstra演算法,關於Dijkstra這裡不作深入展開。)

以下是拼圖狀態結點PuzzleStatus的估價方法,在實際測試中,使用方塊錯位數量來作估價的效果不太明顯,所以這裡只使用曼哈頓距離來作為h(n)估價,已能達到不錯的演算法效率。

/// 估算從當前狀態到目標狀態的代價
- (NSInteger)estimateToTargetStatus:(id<JXPathSearcherStatus>)targetStatus {
    PuzzleStatus *target = (PuzzleStatus *)targetStatus;
    
    // 計算每一個方塊距離它正確位置的距離
    // 曼哈頓距離
    NSInteger manhattanDistance = 0;
    for (NSInteger index = 0; index < self.pieceArray.count; ++ index) {
        // 略過空格
        if (index == self.emptyIndex) {
            continue;
        }
        
        PuzzlePiece *currentPiece = self.pieceArray[index];
        PuzzlePiece *targetPiece = target.pieceArray[index];
        
        manhattanDistance +=
        ABS([self rowOfIndex:currentPiece.ID] - [target rowOfIndex:targetPiece.ID]) +
        ABS([self colOfIndex:currentPiece.ID] - [target colOfIndex:targetPiece.ID]);
    }
    
    // 增大權重
    return 5 * manhattanDistance;
}
複製程式碼

狀態估價由狀態類自己負責,A*演算法只詢問狀態的估價結果,並進行f(n) = g(n) + h(b)操作,確保每一次搜尋,都是待搜空間裡代價最小的狀態,即f值最小的狀態。 那麼問題來了,在給每個狀態都計算並賦予上f值後,如何做到每一次只取f值最小的那個? 前文已講到,所有擴充套件出來的新狀態都會放入開放佇列中的,如果A*演算法也像廣搜那樣只放在佇列尾,然後每次只取隊首元素來搜尋的話,那麼f值完全沒有起到作用。 事實上,因為每個狀態都有f值的存在,它們已經有了優劣高下之分,佇列在存取它們的時候,應當按其f值而有選擇地進行入列出列,這時候需要用到優先佇列(Priority Queue),它能夠每次出列優先順序最高的元素。 關於優先佇列的講解和實現,可參考另一篇文章《藉助完全二叉樹,實現優先佇列與堆排序》,這裡不再展開論述。 以下是A*搜尋演算法的程式碼實現:

- (NSMutableArray *)search {
    if (!self.startStatus || !self.targetStatus || !self.equalComparator) {
        return nil;
    }
    NSMutableArray *path = [NSMutableArray array];
    [(id<JXAStarSearcherStatus>)[self startStatus] setGValue:0];
    
    // 關閉堆,存放已搜尋過的狀態
    NSMutableDictionary *close = [NSMutableDictionary dictionary];
    // 開放佇列,存放由已搜尋過的狀態所擴充套件出來的未搜尋狀態
    // 使用優先佇列
    JXPriorityQueue *open = [JXPriorityQueue queueWithComparator:^NSComparisonResult(id<JXAStarSearcherStatus> obj1, id<JXAStarSearcherStatus> obj2) {
        if ([obj1 fValue] == [obj2 fValue]) {
            return NSOrderedSame;
        }
        // f值越小,優先順序越高
        return [obj1 fValue] < [obj2 fValue] ? NSOrderedDescending : NSOrderedAscending;
    }];
    
    [open enQueue:self.startStatus];
    
    while (open.count > 0) {
        // 出列
        id status = [open deQueue];
        
        // 排除已經搜尋過的狀態
        NSString *statusIdentifier = [status statusIdentifier];
        if (close[statusIdentifier]) {
            continue;
        }
        close[statusIdentifier] = status;
        
        // 如果找到目標狀態
        if (self.equalComparator(self.targetStatus, status)) {
            path = [self constructPathWithStatus:status isLast:YES];
            break;
        }
        
        // 否則,擴充套件出子狀態
        NSMutableArray *childStatus = [status childStatus];
        // 對各個子狀進行代價估算
        [childStatus enumerateObjectsUsingBlock:^(id<JXAStarSearcherStatus>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            // 子狀態的實際代價比本狀態大1
            [obj setGValue:[status gValue] + 1];
            // 估算到目標狀態的代價
            [obj setHValue:[obj estimateToTargetStatus:self.targetStatus]];
            // 總價=已知代價+未知估算代價
            [obj setFValue:[obj gValue] + [obj hValue]];
            
            // 入列
            [open enQueue:obj];
        }];
    }
    NSLog(@"總共搜尋: %@", @(close.count));
    return path;
}
複製程式碼

可以看到,程式碼基本是以廣搜為模組,加入了f(n) = g(n) + h(b)的操作,並且使用了優先佇列作為開放表,這樣改進後,演算法的效率是不可同日而語。

3階方陣,A*演算法平均需要搜尋300個狀態

最後,貼上高難度下依然戰鬥力爆表的A*演算法效果圖:

拼圖遊戲和它的AI演算法

5階方陣下的A*搜尋演算法

原始碼

Puzzle Game:https://github.com/JiongXing/PuzzleGame

相關文章