寫了個拼圖遊戲,探討一下相關的AI算法。拼圖遊戲的復原問題也叫作N數碼問題。git
實現一個拼圖遊戲,使它具有如下功能:github
先看看完成後的效果。點自動按鈕後,遊戲將會把當前的拼圖一步一步移動直到復原圖片。 算法
圖片的選取可經過拍照、從相冊選,或者使用內置默認圖片。 因爲遊戲是在正方形區域內進行的,因此若想有最好的遊戲效果,咱們須要一張裁剪成正方形的圖片。數組
選好圖片後,須要把圖片切割成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 3
、4 x 4
、5 x 5
的方陣。數據結構
假如咱們把遊戲中某個時刻的方塊排列順序稱爲一個狀態,那麼當階數爲n
時,遊戲的總狀態數就是n²
的階乘。 在不一樣難度下進行遊戲將會有很是大的差別,不管是手動遊戲仍是AI進行遊戲。框架
(3*3)! = 362880
個狀態,並很少,即使是最慢的廣搜算法也能夠在短期內搜出復原路徑。(4*4)! = 20922789888000
,二十萬億。廣搜算法已基本不能搜出結果,直到爆內存。(5*5)! = 1.551121004333098e25
,10的25次方。此時不管是廣搜亦或是雙向廣搜都已無能爲力,而A*尚可一戰。在選取完圖片後,拼圖是完好無損的,此時讓第一個被觸擊的方塊成爲空格。 從第二次觸擊開始,將會對所觸擊的方塊進行移動,但只容許空格附近的方塊發生移動。 每一次移動方塊,實質上是讓方塊的位置與空格的位置進行交換。在這裏思惟須要轉個小彎,空格並不空,它也是一個對象,只不過表示出來是一塊空白而已。那麼咱們移動了方塊,是否能夠反過來想,實際上是移動了空格?答案是確定的,而且思惟這樣轉過來後,更方便代碼實現。函數
這裏爲了讓打亂順序後的拼圖有解,採用隨機移動必定步數的方法來實現洗牌。 對於n階方陣,可設計隨機的步數爲:n * n * 10
。在實際測試當中,這個隨機移動的步數已足夠讓拼圖徹底亂序,即便讓隨機的步數再加大10倍,其復原所需的移動步數也變化不大。復原步數與方陣的階數有關,不管打亂多少次,復原步數都是趨於一個穩定的範圍。測試
咱們須要定義一個類來表示拼圖在某個時刻的狀態。 一個狀態應持有如下幾個屬性:優化
同時它應能提供操做方塊的方法,以演進遊戲狀態。
/// 表示遊戲過程當中,某一個時刻,全部方塊的排列狀態
@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
複製代碼
咱們把拼圖在某個時刻的方塊排列稱爲一個狀態,那麼一旦發生方塊移動,就會生成一個新的狀態。 對於每一個狀態來講,它都可以經過改變空格的位置而衍生出另外一個狀態,而衍生出的狀態又可以衍生出另外一些狀態。這種行爲很是像一棵樹的生成,固然這裏的樹指的是數據結構上的樹結構。
推演移動路徑的過程,就是根據當前狀態不斷衍生狀態,而後判斷新狀態是否爲咱們的目標狀態(拼圖徹底復原時的狀態)。若是找到了目標,就能夠原路返回,依次找出目標所通過的全部狀態。 由此,狀態樹中的每個結點都須要提供如下屬性和方法:
/// 狀態協議
@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
複製代碼
關於「搜索」兩字,在代碼上能夠理解爲拿着某個狀態與目標狀態進行比較,若是這兩個狀態一致,則搜索成功;若是不一致,則繼續取另外一個狀態與目標狀態比較,如此循環下去直到找出與目標一致的狀態。 各算法的區別,主要在於它們對搜索空間內的狀態結點有不一樣的搜索順序。
廣度優先搜索是一種盲目搜索算法,它認爲全部狀態(或者說結點)都是等價的,不存在優劣之分。
假如咱們把全部須要搜索的狀態組成一棵樹來看,廣搜就是一層搜完再搜下一層,直到找出目標結點,或搜完整棵樹爲止。
NSMutableDictionary
來存放已搜記錄。咱們能夠給這個存儲空間起個名字叫關閉堆,也有人把它叫作關閉列表(Close List)。廣度優先搜索:
- (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;
}
複製代碼
雙向廣度優先搜索是對廣度優先搜索的優化,可是有一個使用條件:搜索路徑可逆。 搜索原理 雙向廣搜是同時從開始狀態和目標狀態展開搜索的,這樣就會產生兩棵搜索狀態樹。咱們想象一下,讓起始於開始狀態的樹從上往下生長,再讓起始於目標狀態的樹從下往上生長,同時在它們的生長空間中遍及着一個一個的狀態結點,等待着這兩棵樹延伸去觸及。 因爲任一個狀態都是惟一存在的,當兩棵搜索樹都觸及到了某個狀態時,這兩棵樹就出現了交叉,搜索即告結束。 讓兩棵樹從發生交叉的狀態結點各自原路返回構建路徑,而後算法把兩條路徑拼接起來,即爲結果路徑。 可用條件 對於拼圖遊戲來講,已經知道了開始狀態(某個亂序的狀態)和目標狀態(圖片復原時的狀態),而這兩個狀態實際上是能夠互換的,徹底能夠從目標復原狀態開始搜索,反向推動,直到找出拼圖開始時的亂序狀態。因此,咱們的拼圖遊戲是路徑可逆的,適合雙向廣搜。 單線程下的雙向廣搜 要實現雙向廣搜,並不須要真的用兩條線程分別從開始狀態和目標狀態對向展開搜索,在單線程下也徹底能夠實現,實現的關鍵是於讓兩個開放隊列交替出列元素。 在每一次循環中,比較兩個開放隊列的長度,每一次都選擇最短的隊列進行搜索,優先讓較小的樹生長出子結點。這樣作可以使兩個開放隊列維持大體相同的長度,同步增加,達到均衡兩棵搜索樹的效果。
- (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;
}
複製代碼
不一樣於盲目搜索,A算法是一種啓發式算法(Heuristic Algorithm)。 上文提到,盲目搜索對於全部要搜索的狀態結點都是一視同仁的,所以在每次搜索一個狀態時,盲目搜索並不會考慮這個狀態究竟是有利於趨向目標的,仍是偏離目標的。 而啓發式搜索的啓發二字,看起來是否是感受這個算法就變得聰明一點了呢?正是這樣,啓發式搜索對於待搜索的狀態會進行不一樣的優劣判斷,這個判斷的結果將會對算法搜索順序起到一種啓發做用,越優秀的狀態將會獲得越高的搜索優先級。 咱們把對於狀態優劣判斷的方法稱爲啓發函數*,經過給它評定一個搜索代價來量化啓發值。 啓發函數應針對不一樣的使用場景來設計,那麼在拼圖的遊戲中,如何評定某個狀態的優劣性呢?粗略的評估方法有兩種:
其實上述兩種評定方法都只是對當前狀態距離目標狀態的代價評估,咱們還忽略了一點,就是這個狀態距離搜索開始的狀態是否已經很是遠了,亦即狀態結點的深度值。 在拼圖遊戲中,咱們進行的是路徑搜索,假如搜索出來的一條移動路徑其須要的步數很是多,即便最終可以把拼圖復原,那也不是咱們但願的路徑。因此,路徑搜索存在一個最優解的問題,搜索出來的路徑所須要移動的步數越少,就越優。 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)
的操做,而且使用了優先隊列做爲開放表,這樣改進後,算法的效率是不可同日而語。
最後,貼上高難度下依然戰鬥力爆表的A*算法效果圖:
Puzzle Game:https://github.com/JiongXing/PuzzleGame