华容道算法实现

477 阅读7分钟

这是一篇隔了很久才开始执笔的文章,图片摄于重庆,觉得和主题很配,有点历史底蕴,遂放入。起初原因是周末无聊玩到华容道小游戏,准备着手实现类似的功能。华容道可以追溯到三国时期,曹操兵败赤壁,从华容道逃走,而关羽就在其中某处伺机等待,曹操一行人需要避开关羽,才能成功出逃。荷兰人开发出这种益智小游戏,华容道游戏是一种滑块类游戏,由放在方形盘中的10块方片拼成,目标是在只滑动方块而不从棋盘中拿走的情况下,将最大的一块移到底部出口。

本文主要实现的华容道是3*3的棋盘格,如下图Fig 1所示,我们需要做的就是通过棋盘格子和空格之间的交换来进行移动,最后形成Fig 2的结果。

华容道华容道
Fig 1Fig 2

一些准备知识

队列结构

队列结构是FIFO, 通过头尾指针引用达到先进先出 image.png

例如list=[1,2,3,4,5]list = {[1,2,3,4,5]}

入队的过程就如下所示 Dec-15-2021 10-39-05.gif

出队的过程如下所示 Dec-15-2021 10-48-49.gif

树结构

我们可以想象就像走迷宫不断试错,从下图"0"位置开始会从四个方向进行试探,因此就会构成0->4->1或者70->2->1或者30->6->3或者80->5->7或者8这样的一棵树进行深度优先遍历

image.png

C语言的基础知识

  • struct自定义数据结构,它由不同类型的数据组合成一个整体
struct 结构体名 {

    成员列表

};
  • 数组指针和指针数组 数组指针:它是一个指针,它指向一个数组。在32位系统下占4个字节,在64位系统下占8个字节,它是“指向数组的指针”的简称。 image.png

值得注意的是指向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)

指针数组:它是一个数组,数组的元素都是指针,数组占多少个字节由数组本身决定,它是“储存指针的数组”的简称。 image.png

  • gcc编译 image.png

Makefile

makefile更便捷的帮助我们编译

wildcardpatsubs
扩展通配符替换通配符

更多自动变量

实现文件读取

  • 声明文件读取流
  • 读取文件信息(包括换行信息,空格等)
  • 读取结束,关闭流
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;
}

image.png

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;
}

image.png

Dec-15-2021 16-48-01.gif

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
  • 更新位置信息

image.png

image.png

例如我们交换上下位置,代码如下

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状态的打印

image.png

Dec-15-2021 17-35-42.gif

#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图形库是处理终端字符单元显示

image.png

image.png

image.png

image.png

image.png

我们的做法是将最优解的每次状态存入 step(SIZESIZE)** 的board大数组中(由于是1-8的数字所以SIZESIZE为3x3),并且进行逆序存储,将第step步存入最后 **SIZESIZE**数组中,第1步存入到第一个 SIZE*SIZE 数组中

Dec-15-2021 15-55-03.gif

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只老鼠并且只要老鼠喝了有毒的水必死。请问怎样通过一次实验找出有毒的那瓶水。相关链接

image.png

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;
  }
}

可以看下最后实现效果

Dec-16-2021 16-36-33.gif

相关代码以上传到github

image.png

总结

总的来说实现华容道算法主要用了队列的数据结构以及回溯的思想,通过枚举所有情况,优化避免一些重复计算来达到目的,如果有不正确的地方还请各位大佬指出,谢谢!

image.png