这是一篇隔了很久才开始执笔的文章,图片摄于重庆,觉得和主题很配,有点历史底蕴,遂放入。起初原因是周末无聊玩到华容道小游戏,准备着手实现类似的功能。华容道可以追溯到三国时期,曹操兵败赤壁,从华容道逃走,而关羽就在其中某处伺机等待,曹操一行人需要避开关羽,才能成功出逃。荷兰人开发出这种益智小游戏,华容道游戏是一种滑块类游戏,由放在方形盘中的10块方片拼成,目标是在只滑动方块而不从棋盘中拿走的情况下,将最大的一块移到底部出口。
本文主要实现的华容道是3*3的棋盘格,如下图Fig 1所示,我们需要做的就是通过棋盘格子和空格之间的交换来进行移动,最后形成Fig 2的结果。
Fig 1 | Fig 2 |
一些准备知识
队列结构
队列结构是FIFO, 通过头尾指针引用达到先进先出
例如
入队的过程就如下所示
出队的过程如下所示
树结构
我们可以想象就像走迷宫不断试错,从下图"0"位置开始会从四个方向进行试探,因此就会构成0->4->1或者7,0->2->1或者3,0->6->3或者8,0->5->7或者8这样的一棵树进行深度优先遍历
C语言的基础知识
- struct自定义数据结构,它由不同类型的数据组合成一个整体
struct 结构体名 {
成员列表
};
- 数组指针和指针数组
数组指针:它是一个指针,它指向一个数组。在32位系统下占4个字节,在64位系统下占8个字节,它是“指向数组的指针”的简称。
值得注意的是指向xx的指针,指针类型和xx对应的类型并不需要保持一致,这样是合理的但是不推荐
int a[] ={1,2,3,4,5};
int *p = &a[0];
/*在内存中移动了int大小的距离4,也就是数组中2所对应的地址*/
*(p + 1) => &a + sizeof(int);
char *p = &a[0];
/*在内存中移动了char大小的距离1,不能完整打印2*/
*(p + 1) => &a + sizeof(char)
指针数组:它是一个数组,数组的元素都是指针,数组占多少个字节由数组本身决定,它是“储存指针的数组”的简称。
- gcc编译
Makefile
makefile更便捷的帮助我们编译
wildcard | patsubs |
---|---|
扩展通配符 | 替换通配符 |
实现文件读取
- 声明文件读取流
- 读取文件信息(包括换行信息,空格等)
- 读取结束,关闭流
while (1)
{
ch = fgetc(fp);
if (feof(fp))
{
break;
}
printf("%c", ch);
}
fclose(fp);
存储读取结果
我们定义一个board的结构体,结构体内有chess数组,用于存入将从流中读取到的数据
#define SIZE 3
struct: {
int chess[SIZE * SIZE];
int prev;
int pos;
}
int main(int argc, char *argv[])
{
FILE *fp = NULL;
struct board start;
start = get_snapshot(fp);
fclose(fp);
return 0;
}
struct board get_snapshot(FILE *fp)
{
char ch;
int i = 0;
struct board start;
/* 获取1-8的数字 */
while ((ch = fgetc(fp)) != EOF)
{
if (ch == '\n' || ch == '\r')
{
continue;
}
/* 如果遇到为空则用0进行标识 */
if (ch == ' ')
{
start.chess[i] = 0;
}
else
{
start.chess[i] = ch - '0';
}
i++;
}
return start;
}
添加board
我们可以知道由于需要不断的进行路径查询,每一个路径就代表一个状态,所以我们准备设计队列的数据结构,当存在新的path的时候加入到queue中
struct eighttile {
struct board *p;
int first;
int end;
}
int main(int argc, char *argv[])
{
FILE *fp = NULL;
struct board start;
+ struct eighttile e;
/* 分配无限大的空间 */
+ e.q = (struct board *)malloc(sizeof(struct board) * N);
+ memset(e.q, 0, sizeof(struct board) * N);
+ e.first = 0;
+ e.end = 0;
start = get_snapshot(fp);
+ start.prev = -1;
+ add(&e, start);
fclose(fp);
return 0;
}
void add(struct eighttile *e, struct board b)
{
int i;
for (i = 0; i < SIZE * SIZE; i++)
{
e->q[e->end].chess[i] = b.chess[i];
}
e->q[e->end].prev = b.prev;
e->q[e->end].pos = b.pos;
e->end++;
}
弹出board,交换位置
有了第一个board作为基础,接下来就可以根据这个board来进行路径搜索 searchPath,弹出board的条件是first !== end,则就马上进行弹出,可以理解为add我们就是新加入了一个状态,并且马上将这个状态进行弹出,并且进行交换位置,得到新的状态
int main(int argc, char *argv[])
{
FILE *fp = NULL;
struct board start;
struct eighttile e;
/* 分配无限大的空间 */
e.q = (struct board *)malloc(sizeof(struct board) * N);
memset(e.q, 0, sizeof(struct board) * N);
e.first = 0;
e.end = 0;
start = get_snapshot(fp);
start.prev = -1;
add(&e, start);
+ search_path(&e);
fclose(fp);
return 0;
}
void search_path(struct eighttile *e)
{
struct board b;
while (e->end != e->first)
{
pop(e, &b);
}
}
void pop(struct eighttile *e, struct board *b)
{
int i;
for (i = 0; i < SIZE * SIZE; i++)
{
b->chess[i] = e->q[e->first].chess[i];
}
b->pos = e->q[e->first].pos;
b->prev = e->q[e->first].prev;
e->first++;
}
值得注意的是弹出操作并不是真正意义的出栈,而只是代表这个数据将被作为不可用,除此之外由于是树结构,有后续的回溯,所以每次在进行交换的时候我们需要从原先位置copy出来,保证后续回溯回去数据不出错,大概分为以下三个步骤
- copy原先board
- 替换现有的board中的chess
- 更新位置信息
例如我们交换上下位置,代码如下
void find_neighbor(struct board b)
{
/* 获取空格所在行 */
int i = b.pos / SIZE;
/* 获取空格所在列 */
int j = b.pos % SIZE;
if (i - 1 >= 0)
{
/* 1. 保存原先镜像 */
struct board nb;
int c;
for (c = 0; c < SIZE * SIZE; c++)
{
nb.chess[i - 1] = b.chess[c];
}
nb.pos = b.pos;
nb.prev = b.prev;
/* 2. swap 替换现有的board */
swap(&nb.chess[nb.pos], &nb.chess[(i - 1) * SIZE + j]);
/* 3. 更新pos */
nb.pos = (i - 1) * SIZE + j;
}
}
void swap(int *a, int *b)
{
int *tmp;
tmp = a;
a = b;
b = tmp;
}
由于交换位置存在四个方向,所以我们整理得到
void find_neighbor(struct board b, struct eighttile *e)
{
/* 获取空格所在行 */
int i = b.pos / SIZE;
/* 获取空格所在列 */
int j = b.pos % SIZE;
if (i - 1 >= 0)
{
exchange(b, e, i - 1, j);
}
if (i + 1 < SIZE)
{
exchange(b, e, i + 1, j);
}
if (j - 1 >= 0)
{
exchange(b, e, i, j - 1);
}
if (j + 1 < SIZE)
{
exchange(b, e, i, j + 1);
}
}
void exchange(struct board b, struct eighttile *e, int i, int j)
{
/* 1. 保存原先镜像 */
struct board nb;
copy(&nb, &b);
/* swap */
swap(&nb.chess[nb.pos], &nb.chess[i * SIZE + j]);
/* 更新pos */
nb.pos = i * SIZE + j;
/* 更新prev */
nb.prev = e->first - 1;
}
void copy(struct board *dest, struct board *source)
{
int i;
for (i = 0; i < SIZE * SIZE; i++)
{
dest->chess[i] = source->chess[i];
}
dest->pos = source->pos;
dest->prev = source->prev;
}
判断一个流程结束的标志是当队列弹出/进入操作时判断是否按序排成1-8的状态,完成则结束
void search_path(struct eighttile *e)
{
struct board b;
while (e->end != e->first)
{
pop(e, &b);
if(is_solved(b)) {
/* TODO */
return;
}
}
}
int is_solved(struct board b)
{
int i;
if (b.chess[SIZE * SIZE - 1] != 0)
{
return 0;
}
for (i = 0; i < SIZE * SIZE - 1; i++)
{
if (b.chess[i] != i + 1)
{
return 0;
}
}
return 1;
}
计算最短步数,打印移动步骤
这其实是一个巨大的枚举,最终会将步骤最小的结果进行返回,与此返回的同时,还会返回其所指向的prev,最终队列中会形成类似下图所示形状,由于prev储存着上一步的位置,所以最后可以串联成一个完整的步骤,并且在每次回溯前进行board状态的打印
#define N 1000000
struct eighttile
{
struct board *q;
char dist[SIZE][SIZE];
struct board start;
int first;
int end;
+ int min_step;
+ int finish_pos;
};
int main(int argc, char *argv[])
{
FILE *fp = NULL;
struct eighttile e;
struct board start;
/* 分配无限大的空间 */
e.q = (struct board *)malloc(sizeof(struct board) * N);
memset(e.q, 0, sizeof(struct board) * N);
e.first = 0;
e.end = 0;
+ e.min_step = N;
+ e.finish_pos = 0;
start = get_snapshot(fp);
start.prev = -1;
add(&e, start);
search_path(&e);
+ printf("step in total: %d\n", e.min_step);
+ show_path(&e, &(e.q[e.finish_pos]));
fclose(fp);
return 0;
}
void show_path(struct eighttile *e, struct board *b)
{
if (b->prev >= 0)
{
show_path(e, &(e->q[b->prev]));
}
show_board(e, *b);
}
void show_board(struct eighttile *e, struct board b)
{
int i, j;
for (j = 0; j < SIZE; j++)
{
for (i = 0; i < SIZE; i++)
{
e->dist[j][i] = ' ';
}
}
for (j = 0; j < SIZE; j++)
{
for (i = 0; i < SIZE; i++)
{
if (b.chess[i * SIZE + j] == 0)
{
e->dist[i][j] = ' ';
}
else
{
e->dist[i][j] = b.chess[i * SIZE + j] + '0';
}
}
}
printf("第%d次移动\n", ++COUNT);
for (i = 0; i < SIZE; i++)
{
for (j = 0; j < SIZE; j++)
{
printf("%c", e->dist[i][j]);
}
printf("\n");
}
printf("\n");
}
实现动画效果
我们通过curses库来实现,curses图形库是处理终端字符单元显示
我们的做法是将最优解的每次状态存入 step(SIZESIZE)** 的board大数组中(由于是1-8的数字所以SIZESIZE为3x3),并且进行逆序存储,将第step步存入最后 **SIZESIZE**数组中,第1步存入到第一个 SIZE*SIZE 数组中
int main(int argc, char *argv[])
{
FILE *fp = NULL;
struct eighttile e;
struct board start;
/* 分配无限大的空间 */
e.q = (struct board *)malloc(sizeof(struct board) * N);
memset(e.q, 0, sizeof(struct board) * N);
e.first = 0;
e.end = 0;
e.min_step = N;
e.finish_pos = 0;
start = get_snapshot(fp);
start.prev = -1;
add(&e, start);
search_path(&e);
printf("step in total: %d\n", e.min_step);
show_path(&e, &(e.q[e.finish_pos]));
+ animation(e);
fclose(fp);
return 0;
}
void show_path(struct eighttile *e, struct board *b, int step)
{
if (b->prev >= 0)
{
show_path(e, &(e->q[b->prev]), step - 1);
}
show_board(e, *b, step);
}
/* 扩容存储所有有效的board */
void show_board(struct eighttile *e, struct board b, int step)
{
int i, j;
int ds;
+ ds = step * SIZE;
for (i = ds; i < ds + SIZE; i++)
{
for (j = 0; j < SIZE; j++)
{
e->dist[i][j] = ' ';
}
}
+ for (i = ds; i < ds + SIZE; i++)
+ {
+ for (j = 0; j < SIZE; j++)
+ {
+ if (b.chess[(i - ds) * SIZE + j] == 0)
+ {
+ e->dist[i][j] = ' ';
+ }
+ else
+ {
+ e->dist[i][j] = b.chess[(i - ds) * SIZE + j] + '0';
+ }
+ }
+ }
}
void animation(struct eighttile e)
{
int i = 0;
initscr();
start_color();
init_pair(1, COLOR_RED, COLOR_YELLOW);
attrset(COLOR_PAIR(1));
clear();
do
{
moving_and_sleeping(&(e.dist[i][0]), SIZE, SIZE);
i += SIZE;
} while (e.min_step--);
endwin();
}
void moving_and_sleeping(char *a, int width, int height)
{
int i, j;
clear();
for (j = 0; j < height; j++)
{
move(j, 0);
for (i = 0; i < width; i++)
{
int c = a[j * SIZE + i];
addch(c);
napms(100);
}
}
refresh();
}
优化
我们可以发现一个巨大的优化点就在于每次加入或者弹出board都太过于自由,没有进行一定的限制。
因此我们初步想到一个容易的方式
- 在加入board前进行是否存在的判断是否存在重复,思路就是从first到end遍历队列,对每个board中的chess数组进行依次比较
void exchange(struct board b, struct eighttile *e, int i, int j)
{
...
if (can_insert(e, nb))
{
add(e, nb);
}
}
int can_insert(struct eighttile *e, struct board b)
{
int i, j;
for (i = e->first; i != e->end;)
{
for (j = 0; j < SIZE * SIZE; j++)
{
if (b.chess[j] != e->q[i].chess[j])
{
break;
}
}
if (j == SIZE * SIZE)
{
break;
}
i++;
}
if (i == e->end)
{
return 1;
}
else
{
return 0;
}
}
-
另一个比较不错的方式,灵感来自下面的题目,所以我们可以将队列中的board中的chess数组考虑为一种状态,更确切地说考虑为一个整体一个数字,然后分为8个一组(例如:123406758 / 8),进行与或操作
1000瓶水其中有一瓶水有毒,有10只老鼠并且只要老鼠喝了有毒的水必死。请问怎样通过一次实验找出有毒的那瓶水。相关链接
void add(struct eighttile *e, struct board b)
{
int i;
int rst = 0;
for (i = 0; i < SIZE * SIZE; i++)
{
e->q[e->end].chess[i] = b.chess[i];
+ rst = rst * NUMBER + b.chess[i];
}
+ e->h[rst / EIGHT_BIT] = e->h[rst / EIGHT_BIT] | (1 << (rst % EIGHT_BIT));
e->q[e->end].prev = b.prev;
e->q[e->end].pos = b.pos;
e->end++;
}
int can_insert(struct eighttile *e, struct board b)
{
int i;
int rst = 0;
for (i = 0; i < SIZE * SIZE; i++)
{
rst = rst * NUMBER + b.chess[i];
}
if (e->h[rst / EIGHT_BIT] & (1 << (rst % EIGHT_BIT)))
{
return 0;
}
else
{
return 1;
}
}
可以看下最后实现效果
相关代码以上传到github
总结
总的来说实现华容道算法主要用了队列的数据结构以及回溯的思想,通过枚举所有情况,优化避免一些重复计算来达到目的,如果有不正确的地方还请各位大佬指出,谢谢!