拼圖遊戲和它的AI算法

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

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

遊戲設定

實現一個拼圖遊戲,使它具有如下功能:github

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

自動完成拼圖復原

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

自動復原.gif

圖片與方塊

圖片的選取可經過拍照、從相冊選,或者使用內置默認圖片。 因爲遊戲是在正方形區域內進行的,因此若想有最好的遊戲效果,咱們須要一張裁剪成正方形的圖片。數組

截取正方形區域.png

選好圖片後,須要把圖片切割成n x n塊。這裏每個方塊PuzzlePiece都是一個UIButton。 因爲圖片是會被打散打亂的,因此每一個方塊應該記住它本身在原圖上的初始位置,這裏給方塊添加一個屬性ID,用於保存。bash

@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*算法效果圖:

5階方陣下的A*搜索算法

源碼

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

相關文章
相關標籤/搜索