微信扫一扫

028-83195727 , 15928970361
business@forhy.com

拼图游戏N数码问题AI算法实现

人工智能,拼图复原,Astar算法,N数码问题,图搜索2016-08-08

游戏简介


最近写了一个5*5拼图游戏的复原算法,拼图复原问题又叫N数码问题,由于搜索空间巨大,可以用来作为测试算法效率的工具。

先放上效果图(图片大小700K,加载可能比较慢):


这个是用javascript做的一个算法的图形演示版,整个算法刚开始是用c++完成的,为了让算法的效果更生动所以用把它移植到了javascript上,这篇博客主要讲用c++的实现方法,js与c++的差别会在讲解中提到。以下是项目地址:

js版本:https://github.com/chuyangLiu/NPuzzle-AI

c++版本:https://github.com/chuyangLiu/DataStructureAndAlgorithm(这个版本库存放了许多算法与数据结构,拼图算法是其中一个,源文件是NPuzzle.h和NPuzzle.cpp)


拼图储存


       首先我们要确定如何表达或者储存当前整个拼图的状态,这里我使用一维数组储存,复原成功时的拼图储存为[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 0]。在5*5的拼图中,空白格子有1个,其他24个格子存放拼图块图案,复原成功时的拼图空白格在右下角,剩下的格子编号为1~24,因此有了上述一维数组的状态。有了拼图状态的储存方法,我们可以为每一个状态提供一个最基本的操作:move(),即移动当前拼图中的空白格子。举个例子,从复原成功的拼图开始,move(up)后,数组内容变为[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 0, 21, 22, 23, 24, 20],即空白格子向上移动,与第20块拼图块交换位置。

算法简介


       我们将拼图的一个状态简称为一个节点(node),拼图还原的算法可以概括为找到从节点A到节点B的一条路径P(一个移动方向的集合),使得从节点A开始依次按照P中的移动方向调用move()后,节点A的一维数组内容与节点B一致。
       这个算法很显然可以使用广度优先搜索BFS完成, 我们先给出搜索算法中需要用到的定义:
       1. 邻接节点(adjacent node):节点N的邻接节点通过对节点N分别调用move(left),move(up),move(right),move(down)得到,注意有些节点是不能向某个方向移动的(比   如空白格子在最后一行时就不能向下移动等),此时该方向没有邻接节点。按照此定义,一个节点的邻接点个数在闭区间[2, 4]内。
       2. 边权(edge weight):每个节点N和它所有的邻接节点之间的边权设为1。
       算法的图示如下(为了简化,描述的是3*3拼图,5*5拼图是类似的):
       

       有了这些定义,已经能写出dijkstra算法求解两个节点的最短路径了,但是5*5拼图的搜索空间十分庞大,简单使用dijkstra是难以搜出结果的,因此我们需要使用A*算法,在dijkstra算法中,我们每次从open list中选取distance值最小的节点进行搜索,与dijkstra不同的是,A*算法每次选择F (=distance+heuristic)值最小的节点进行搜索,这里的distance值与dijkstra算法中一样,新增的heuristic表示当前结点到目标节点的估算距离,估算距离的好坏可以极大的影响算法的效率。有关A*算法的详细介绍这里给个链接:http://theory.stanford.edu/~amitp/GameProgramming/AStarComparison.html 

代码实现

节点封装

我们将一个节点的状态以及与节点相关的操作封装在一个类NPuzzleNode中:
/*
N-Puzzle node definition.

The value of the node must be like this:
{1, 2, 3, 4, 5, 6, 7, 8, 0} (row = 3, col = 3)
Value 0 indicates the empty grid in the puzzle. 
*/
class NPuzzleNode {
public:
    ~NPuzzleNode();

    /*
    Initialize the node.

    @param val_ the node value
    @param row_ the row number
    @param col_ the column number
    */
    NPuzzleNode();
    NPuzzleNode(const std::vector<int> &val_, const int row_, const int col_);

    /*
    Get the node value.
    */
    const std::vector<int>& getVal() const;

    /*
    Get the adjacent node at the direction.
    Precondition: current node can move to the direction

    @param direc the direction
    @return the adjacent node pointer (make sure to delete
            because the pointer is allocated dynamically)
    */
    NPuzzleNode* getAdjNode(const Direction &direc) const;

    /*
    Move the empty grid toward the direction

    @param direc the direction
    */
    void move(const Direction &direc);

    /*
    Check whether the empty grid can move toward the direction

    @param direc the direction
    @return true if can move, false otherwise
    */
    bool canMove(const Direction &direc) const;

    /*
    Randomly rearrange the node value.
    (Make sure a solution exists)
    */
    void shuffle();

    /*
    Get the row number of the index

    @param i the index
    @return the row number (range: [0, row - 1])
    */
    int getRow(const int &i) const;

    /*
    Get the column number of the index

    @param i the index
    @return the column number (range: [0, col - 1])
    */
    int getCol(const int &i) const;

    /*
    Get the element numbers of the node value.
    */
    int getSize() const;

    /*
    Get the string description of the node value.

    @return the string description
    */
    std::string toString() const;

    /*
    Hash function for a node

    @return the hash value of the node
    */
    unsigned long long hash() const;

    /*
    Check if two nodes equal.
    */
    bool operator==(const NPuzzleNode &a) const;

    /*
    Compare two nodes by their f value.
    */
    bool operator<(const NPuzzleNode &a) const;
    bool operator>(const NPuzzleNode &a) const;
    bool operator<=(const NPuzzleNode &a) const;
    bool operator>=(const NPuzzleNode &a) const;

    /*
    Getters and setters for fields used in searching algorithm.
    */
    void setG(const int g_);
    void setH(const int h_);
    void setParent(NPuzzleNode* p);
    void setDirection(const Direction &d);
    int getG() const;
    int getH() const;
    int getF() const;
    NPuzzleNode* getParent() const;
    Direction getDirection() const;

private:
    std::vector<int> val;
    int emptyPos = -1;
    int row = 0;
    int col = 0;
    int g = 0;                      // The distance from the beginning node to the current node
    int h = 0;                      // The distance from the current node to the goal node (heuristic)
    NPuzzleNode* parent = nullptr;  // Parent node pointer
    Direction direc = NONE;         // Direction from parent node to this node

    /*
    Initialize the node value.

    @param val_ the node value
    @throw range_error if the node value is not valid
    */
    void init(const std::vector<int> &val_);
};
与节点相关的大部分操作比较简单,这里只列出头文件,源码可以从以下地址获得:
https://github.com/chuyangLiu/DataStructureAndAlgorithm/blob/master/src/NPuzzle.cpp
注意到NPuzzleNode中有一个hash()函数,这将在后面的哈希策略里讲到。

选取F值最小的节点策略(open list)

        A*算法每次从open list中拿到F值最小的点进行处理,使用优先队列(堆)再合适不过了。优先队列的实现方法有多种,这里使用最经典的完全二叉树的实现。在c++的实现中,可以使用标准库的priority_queue完成,当然也可以自己写一个,这两种方式我都实现了,自己写的比标准库的效率稍高一些,利用typedef语句,可以在两种实现方式之间切换:
    /*
    Comparator for binary heap
    */
    struct cmpBinaryHeap {
        bool operator()(const NPuzzleNode *const &a,
                        const NPuzzleNode *const &b) const {
            return *a <= *b;
            //return *a > *b;  // STD version
        }
    };

    /*
    Min-root binary heap declaration.
    */
    typedef BinaryHeap<NPuzzleNode*, cmpBinaryHeap> min_heap;
    typedef std::priority_queue<NPuzzleNode*, std::vector<NPuzzleNode*>, cmpBinaryHeap> min_heap;  // STL version
       为了提高运行效率,在堆中存放节点的指针,利用一个struct cmpBinaryHeap仿函数(functor)完成对两个节点的F值比较。注意标准库中的priority_queue默认是个大顶堆,所以比较策略需要使用大于才能产生一个小顶堆。
       在javascript的实现中,由于库中没有堆,需要自己实现一个,当然,我在文章开头给出的两个项目链接中都写好了。

哈希策略(close list)

        A*算法中需要记录所有已经访问过的节点(放入close list中),使用哈希表(hash table)实现再合适不过了,利用NPuzzleNode中的hash()函数,我们可以将一个节点映射成一个非负值从而存在哈希表的一个桶中。哈希函数的选取也会对性能造成很大影响,这里有两种策略,一种是使用节点数组值的字符串表示(toString()的返回值)来进行哈希,一种是使用康托展开,这两种方法的效率都不差,但由于康托展开需要计算阶乘,在5*5数码问题中最少要计算24!,这个结果需要使用高精度储存,unsigned long long类型会产生溢出,因此对哈希的性能会有一定的影响。在实现中,对比这两种方法发现,在g++编译器下,使用字符串哈希较快。在javascript实现中,使用字符串哈希效果比康托展开好很多。(关于康托展开的介绍:https://zh.wikipedia.org/wiki/%E5%BA%B7%E6%89%98%E5%B1%95%E5%BC%80
       在c++实现中,可以使用标准库的unordered_set完成哈希表的功能,当然也可以自己写一个,这两种实现方式我都写了,使用自己写的和标准库的效率没有明显区别,利用typedef语句,可以在两种实现方式之间切换:
    /*
    Comparator for hash table
    */
    struct cmpHashTable {
        bool operator()(const NPuzzleNode *const &a,
                        const NPuzzleNode *const &b) const {
            return *a == *b;
        }
    };

    /*
    Hash table declaration.
    */
    typedef HashTable<NPuzzleNode*, cmpHashTable> hash_table;
    typedef std::unordered_set<NPuzzleNode*,std::function<unsigned long long(const NPuzzleNode *const &)>, cmpHashTable> hash_table;  // STL version
       为了提高运行效率,哈希表中同样存放的是节点的指针,利用一个struct cmpHashTable仿函数(functor)完成对两个节点的等值比较。

Astar估值函数的选取

       前面提到过,A*算法每次从open list中选取F值最小的节点进行处理,F值包含一个从起始节点到当前节点最短路径的长度(distance)以及一个从当前节点到目标节点的估算距离(heuristic),对估算距离的选取会极大影响搜索的成功率与效率,为了简化描述,我们设g=distance,h=heuristic,此时F = g + h。
       注意到,当h等于0时,算法退化成dijkstra最短路径算法,此时只要搜索成功,路径一定是最短路。当g等于0时,节点搜索次序完全由h值决定,h值选取的好的话可能很快搜索出到目标节点的路径,但是很显然,这时就不能保证路径是最短路了。因此,g和h所占比重是由算法具体要求决定的,比如你想让搜索出的结果路径尽量短,g的比重就应该大,想让搜索到目标节点的速度加快,h的比重就应该大。
       接下来就是如何计算h值的问题了,h值的计算通过函数getEstimate()得出:
int NPuzzle::getEstimate(const NPuzzleNode *const n) const {
    const auto &val = n->getVal();
    const auto &desVal = des.getVal();
    const auto &size = n->getSize();

    // Number of nodes whose next node is in a wrong position
    int wrongNext = 0;
    for (int i = 0; i < size - 1; i++) {
        if (val[i] + 1 != val[i + 1]) {
            wrongNext++;
        }
    }

    // Number of nodes which are in a wrong position
    int wrong = 0;
    for (int i = 0; i < size; ++i) {
        if (val[i] != desVal[i]) {
            ++wrong;
        }
    }

    // Sum up the distance of each element
    int manhatten = 0, geometric = 0;
    for (int i = 0; i < size; ++i) {
        if (val[i]) {  // Escape value 0
            int curR = n->getRow(i);
            int curC = n->getCol(i);
            int desR = n->getRow(val[i] - 1);
            int desC = n->getCol(val[i] - 1);
            int dR = curR > desR ? curR - desR : desR - curR;
            int dC = curC > desC ? curC - desC : desC - curC;
            manhatten += dR + dC;
            geometric += (int)(sqrt(dR * dR + dC * dC));
        }
    }

    return 5 * (1 * wrongNext + 0 * wrong + 2 * manhatten + 1 * geometric);
}
       该函数传入当前节点,然后返回从当前节点到目标节点的估算距离。我一共用了4个估算值,后续节点不正确个数(wrongNext)、放错位置个数(wrong)、所有拼图块到目标块的曼哈顿距离(manhatten)以及所有拼图块到目标块的几何距离(geometric),分别的权重为1、0、2、1,因为测试发现wrong值的效果不是很好,权重就暂时设为了0,前面乘上的系数5是对h值的放大。在目前这种估算值的策略下,算法平均每毫秒搜索70个点,在搜索8000个点左右找到一条长度平均为300的路径。当然,调整这些权重会随时改变算法的效果。

搜索算法实现

A*算法的执行封装在类NPuzzle中,这里放上最核心的函数run():
void NPuzzle::run() {
    searchedCnt = 0;
    openList.push(&src);
    while (!openList.empty()) {

        // Loop until the open list is empty or finding
        // a node that is not in the close list.
        NPuzzleNode *cur = nullptr;
        do {
            cur = openList.top();
            openList.pop();
        } while (!openList.empty() && isVisit(cur));

        // If all the nodes in the open list is in the
        // close list, then there is no available path
        // between the two nodes.
        if (openList.empty() && isVisit(cur)) {
            return;
        }

        ++searchedCnt;
        closeList.insert(cur);
        //printSearchInfo(cur);

        if (*cur == des) {  // Find goal
            constructPath(cur);
            freeResources();
            return;
        }

        for (int i = 1; i <= 4; ++i) {  // Traverse adj
            Direction d = Direction(i);
            if (cur->canMove(d)) {
                NPuzzleNode *adj = cur->getAdjNode(d);
                alloc.push_back(adj);  // alloc用于记录所有动态分配的内存,在上面的freeResources()函数中将会一并释放这些内存
                if (!isVisit(adj)) {
                    adj->setParent(cur);
                    adj->setDirection(d);
                    adj->setG(cur->getG() + 1);
                    adj->setH(getEstimate(adj));
                    openList.push(adj);
                }
            }
        }
    }
}
算法没有太大变化,基本就是广搜的模板,唯一的区别是每次从open list中拿F值最小的节点时,需要反复弹出队首节点直到该节点未被访问过(不在close list内),因为同一个节点可能入堆多次。

其余代码可以在以下项目地址中获得:

js版本:https://github.com/chuyangLiu/NPuzzle-AI

c++版本:https://github.com/chuyangLiu/DataStructureAndAlgorithm

如有意见或建议,欢迎指出。