大家好!这是我的第一篇博客,由于之前没有撰写博客的经验,并且也是初入计算机和人工智能领域,可能有些表述或者理解不当,还请大家多多指教。
一、撰写目的
由于这个学期在上算法与数据结构课程的时候,其中一个大作业是用C语言和深度优先(DFS)的 IDA*(基于迭代加深的A*算法)实现快速寻求15Puzzle(4乘4迷题)的解法的工具,同时尽可能地加入优化使得算法尽可能快速、简练。我发现网上很少有关于利用IDA*去解决15乃至24Puzzle的介绍,于是我就想跟大家分享一下自己的学习经验和解决方法,文章中大多理念都是有我自己归纳总结的地方,只是为了给大家粗略地介绍一下并不能涵盖这些知识的全部方面,希望能给大家一点帮助。
二、15Puzzle(4乘4迷题)及其一般算法简介
1. 15-Puzzle:15Puzzle(4乘4迷题)看似陌生,但其实肯定每个人都知道甚至玩过类似的衍生游戏,在此我们以15Puzzle为例给大家介绍。15Puzzle是一个由16个宫格以4乘4方式排列的组成的图案,通常是以益智类游戏的方式出现,16个宫格中有15个具有数字编号或图案,剩下一个为空格。当16个格子按照一定顺序排列成为“最终状态”时,其数字编号也会按照顺序排列,或是其图案会一个大的图片,如下方就是数字形式的15Puzzle的“最终状态”:
B 1 2 3
4 5 6 7
8 9 10 11
12 13 14 15
注:B代表空格子,B可位于任何一个角落,取决于不同的游戏规则
在游戏初始状态时,所有宫格为乱序排列,如:
14 13 15 7
11 12 9 5
6 B 2 1
4 8 10 3
一次移动中,玩家只可以将空格(B)与其相邻的某个格子互换位置,达到一个新的状态。如上图第一步只可能为将B与12,6,2,或8交换位置。玩家若通过多次移动,将全部宫格恢复到“最终形态”后,游戏即结束,为了方便起见,在程序中我们采用0代替B。
为了直观地说明算法时间复杂度的差异,在这里我采用了6组随机但必有解的初始状态,分别为:
N | 初始状态 |
1 | 14 13 15 7 11 12 9 5 6 0 2 1 4 8 10 3 |
2 | 13 5 4 10 9 12 8 14 2 3 7 1 0 15 11 6 |
3 | 14 7 8 2 13 11 10 4 9 12 5 0 3 6 1 15 |
4 | 5 12 10 7 15 11 14 0 8 2 1 13 3 4 9 6 |
5 | 7 6 8 1 11 5 14 10 3 4 9 13 15 2 0 12 |
6 | 15 2 12 11 14 13 9 5 1 3 8 7 0 10 6 4 |
注:每个初始状态用数列表示,第一个代表最左上角格子,N1即为上方初始状态实例的数列表示,0代表空格
2. 穷举算法:而所谓关于15-Puzzle的算法,其目的无非就是用尽可能少的时间、尝试尽可能少状态,在解法存在的情况下,找到步数最少的最优解法。其中当然最简单的即为穷举法,我们由初始状态出发,迭代尝试所有可能的状态,即穷举出只移动一步可以达到的状态、两步可以达到的状态...直到尝试得到“最终状态”,这里的算法可以尝试广度优先搜索(Breath-first Search, BFS),以层(步数)为单位向外拓展搜索直到达到最终状态,实现既可以使用迭代也可以不使用,这里就不详细阐述了。穷举算法的复杂度相当之高(理论上最多会查看1013个状态,普通笔记本电脑可能会花费数十分钟至数十小时来求解),在此由于时间关系我就没有进行实验,有兴趣的朋友可以尝试一下。
3. 最简单的优化 - 不走回头路:在最简单的穷举法中我们可以发现,在尝试超过一步移动所达到的状态时,程序常常会先将空格向左移(比如互换0和2),然后在下一步再将空格向右移(还是互换2和0),那这样其实又回到了2步前的状态,这样子的两步也有可能能解出答案,但并不是我们寻找最优解所需要的。所以为了避免这种“恢复原状态的移动”的出现,我的做法是对于每个出现的状态,记录到达这个状态所使用的移动方式(针对空格),比如当前状态是由前一状态将空格向右移产生的,表示状态的数据结构中就会存储“右移”的表示(比如一个宏定义#define的数字),这样在前往下一状态时,程序只会去尝试上、下、左三个方面的移动空格。由此一来,每一次的迭代从最多4种可能变为最多3种,时间复杂度进一步降低,但还是需要很多时间去穷举,同样由于时间限制我没有进行实验。
三、IDA*、启发式算法(曼哈顿距离)及DFS简介
深度优先搜索 (Depth-first Search, DFS) 是常用的图算法之一,假设图由节点 (Node) 和连接节点的线段 (Edge) 构成,我们的目的是想从某一节点出发,通过一些线段到达另一个未知位置的节点,那么DFS算法会尽可能地深入地去搜索一条路径,直到无法前往下一个没有被访问过的节点,然后才开始尝试第二条路径。如下图所示,假设起点为A,目标点为F,向斜下方移动的优先级高于向斜上方移动(在程序中具体表现为先向斜下方搜索),那么DFS会先向斜下方尽可能深入地去搜索,即尝试ACD路线,在未找到目标节点时向上返回一步至C,向另一未搜过的方向即CE路径尝试,随后再次返回C,由于C的子节点E,D都被搜索过了,因此再向前返回至A,尝试ABE路线,返回B尝试BF路线,找到F返回成功搜索。
曼哈顿距离在平面直角坐标系中,并不是两点之间的直线距离,而是两点间X坐标值之差和Y坐标值之差的和,如下图红线和公式所示,左图来源于百度:
直观表示: 公式: 其中
在15Puzzle问题中,曼哈顿主要用于估计当前状态距离目标状态的“距离”,也就是所需最少的移动步数,用来限制DFS搜索的深度(不然DFS会沿着一条路径一直搜索下去,即使得到结果很有可能也不是最小步数),这里的启发距离是可容许的(admissible)的,即最终的实际最小距离会大于等于启发距离。在所需移动步数较少时,往往两者是相等的,在所需步数较多时,常常会出现较为复杂的移动,因此实际距离很可能会大于启发距离,所以我们需要在启发距离内无法找到目标的时候,更新增大距离,使得算法往更深一层次搜索,计算曼哈顿距离的核心代码如下:
int manhattan( int* state ) {
for (i = 0; i < NUM_STATES; i++) {
if (state[i]) {
sum += abs(state[i] / STATES_PERLINE - i / STATES_PERLINE) + abs(state[i] % STATES_PERLINE - i % STATES_PERLINE);
}
}
}
其中,sum为最后的曼哈顿距离返回值,state是包含全部方格的数组,在15PUZZLE中NUM_STATES为16,STATES_PERLINE为4。
基于迭代加深的A*算法 (IDA*) 是A* 算法的优化,两者虽然时间复杂度相似,但IDA* 采用深度优先搜索(DFS)的策略,使得程序对内存空间的占用由指数级增长降为线性增长 (程序使用内存与搜索深度呈线性关系),大大减少了内存的使用。总的来说,IDA*会根据一个启发式方法(这里我们采用曼哈顿距离),算出所谓的“启发式评价” (即预估采用最优方案达到最终状态所需要的步数),然后开始循环一定数量的DFS。每个DFS不仅会记录其已经搜索的深度(即距离初始状态已移动的步数),还会在每移动一步到达一个新的状态时,对该状态重新计算“启发式评价”得到属于该状态的评价步数。如果我们采用的是Admissible的启发式评价算法,那么不难知道:
已搜索的深度 + 当前启发式评价步数 = 到达理想状态所需的最小步数
若这个和大于最初的启发式评价,我们便可以认为这个搜索没有必要再进行下去了,因为这个搜索路径的所需的最小步数已经高于我们预估的最佳方案了。所以,一个DFS终止的条件有二,一是它的已搜索的深度与当前启发式评价步数之和大于预估的步数了,二是它搜索到了最终状态,即找到了最优路径。
当然,如上所述,预估的启发式评价通常在最佳步数本身就比较少时比较准确,当需要完成的步数很多时,启发式评价得到的预估步数往往会比真实值小上一些,这时候在预估步数的限制下,所有DFS都不会达到最终状态。所以在所有DFS都进行完成但没有最终状态出现的时候,我们就需要更新总的预估步数,将步数增加使得DFS可以达到更深的深度。这里更新预估步数值可以采用取之前一轮DFS中,所有超出预估步数的和 (已搜索的深度+当前启发式评价步数) 里面,最小的那个值,这样可以确保新的一轮DFS只会往更深的一层而不是多层去搜索。更新预估步数值后,即可开始新一轮的DFS,如此往复直到搜索由于找到最终状态而停止。
IDA* 核心代码主要由两个部分构成,第一个部分为控制循环部分,该部分初始宫格、启动启发距离下的DFS,若未搜索得到结果更新启发距离再次启动搜索直到得到结果(因此这里我们使用的所有测试都是有解得的,否则则会陷入死循环),其核心代码如下:
/* compute initial threshold B */
initial_node.f = threshold = manhattan( initial_node.state ); while (!r) {
newThreshold = INT_MAX; //assign initial node to n (注:代码中无该方法,为具体实现模块,省略原因只为简洁起见)
init(&n, initial_node)//update threshold and recursively find path
r = ida(&n, threshold, &newThreshold); //if can't find solution under current threshold, increase it and retry
if (!r) {
threshold = newThreshold;
}
}
}
其中,r是返回的节点,ida是DFS功能,若找到目标节点则返回该节点,否则返回null值,initial_node和n都是节点数据结构,包含state数组(当前各方格内容)、f(当前节点的启发评价)、a(前一节点到达该节点采用的移动)、g(距离初始状态已走步数)。
第二个部分就是一个递归实现IDA*深度优先算法的,每一次都会将空白方格与某个方向的临近方格交换位置,得到更深一层的节点并更新相关记录数据、重新计算启发式评价。若评价大于初始评价 (递归开始前,在控制循环部分计算的评价),则不往更深的层次进行搜索,退回上一层次并取消更新的数据,返回null值,值得注意的是,程序会记录所有超出初始评价的值中的最小值,便于控制循环中的更新评价。若是评价小于初始评价,那么程序就会往更深的一层递归搜索,直到找到目标状态并返回。核心代码如下:
{
// check if reach end before moving
if (manhattan(node->state) == 0)
return node; //for any action applicable in given state node->s
for (a = 0; a < 4; a++) {
//when the move is applicable and doesn't go back to previous state
if (applicable(a) && (a + node->a) % 4 != 1) {
apply(node, a);// pruning the node
if (node->f > threshold) {
*newThreshold = (*newThreshold < node->f ? *newThreshold : node->f); // apply the oppsite movement of a to get back to parent node
reverse(node, a);
}
else {
if (manhattan(node->state) == 0)
return node; // keep recursively generating
r = ida(node, threshold, newThreshold); if (r) {
return r;
} else {
reverse(node, a);
}
}
}
}
return( NULL );
}
其中,a代表了四种空白方格的移动(上下左右),node为节点数据结构,applicable函数判断该空白方格的移动方向是否可行 (全局变量检测空白格的位置,主要检测不让其移出边界),apply函数对节点node执行某方向a的移动操作并返回新节点,newThreshold为记录超出初始启发式评价中的值的最小值,reverse函数对节点node执行a方向相反的移动,即返回上一层节点。
实验结果:
采用上述6组随机但必有解的初始状态作为实验,运行不走回头路的IDA*深度优先算法,我们可以得到:
ID(N) | h(s0) | Threshold | Solution | Generated | Expanded | Time/sec | Expanded/Second |
1 | 41 | 41 43 45 47 49 51 53 55 57 | 57 | 499,911,606 | 253,079,560 | 17.81 | 14,211,627 |
2 | 43 | 43 45 47 49 51 53 55 | 55 | 18,983,862 | 9,777,810 | 0.71 | 13,751,593 |
3 | 41 | 41 43 45 47 49 51 53 55 57 59 | 59 | 455,125,298 | 229,658,354 | 16.09 | 14,276,552 |
4 | 42 | 42 44 46 48 50 52 54 56 | 56 | 82,631,583 | 41,689,053 | 3.14 | 13,260,319 |
5 | 41 | 41 43 45 47 49 51 53 55 57 59 | 59 | 937,956,626 | 475,109,930 | 35.09 | 13,540,551 |
6 | 43 | 43 45 47 49 51 53 55 57 59 61 63 65 | 65 | 6,195,467,140 | 3,176,234,868 | 218.86 | 14,512,743 |
其中, h(s0)代表了初始状态的启发式评价;Threshold代表了控制循环部分中启发式评价更新的过程;Solution代表最终达到目标状态所需的最少步数;Generated表示全过程产生的所有节点数量(包括重复计算的节点),如果用B+树表示搜索过程的话就是树中所有的节点;Expanded表示全过程产生的、启发式评价低于每轮循环初始评价的节点数量,在B+树中就是所有的非叶节点;Time/sec就是在我个人电脑上运行算法的时间;Expanded/Second代表了一个计算速率。
可以发现,对于这些较为复杂的不同例子,计算所进行的深度、广度都会有很大差别,尽管他们可能有着相似的初始启发式评价,在近似的计算速率下,有些例子仅需不到1秒就可以得到结果,而有些则需要接近四分钟。
注:IDA* 和曼哈顿距离启发是由Korf等于1985年第一次应用到15-Puzzle上的
四、补充
有关15或者24Puzzle的优化其实还有很多,我会在空余时间继续学习和理解,完成之后继续分享到我的博客上。
课程指导老师:Nir Lipovetzky(nir.lipovetzky@unimelb.edu.au) 和 Grady Fitzpatrick(grady.fitzpatrick@unimelb.edu.au)
五、相关链接:
代码github链接: https://github.com/Simon531/15Puzzle-Solver-C-IDAStar-DFS-Heuristic
本人邮箱:cxgsimon@outlook.com
如有翻译或理解有误,或是代码不规范不简洁的地方,还欢迎大家多多提出指正,谢谢!
2019年01月13日