写了个拼图游戏,探讨一下相关的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