基于JavaScript求解八数码最短路径并生成动画效果

1,875 阅读8分钟
原文链接: github.com

写在最前

本次分享一下通过广度优先搜索解决八数码问题并展示其最短路径的动画效果。

欢迎关注我的博客,不定期更新中——

效果预览

该效果为从[[2, 6, 3],[4, 8, 0],[7, 1, 5]] ==> [[[1, 2, 3],[4, 5, 6],[7, 8, 0]]]的效果展示

2018-01-28 20_50_42

源码地址

配置方式如下:

var option = {
    startNode: [
        [2, 6, 3],
        [4, 8, 0],
        [7, 1, 5]
    ],
    endNode: [
        [1, 2, 3],
        [4, 5, 6],
        [7, 8, 0]
    ],
    animateTime: '300' //每次交换数字所需要的动画时间
}
var eightPuzzles = new EightPuzzles(option)

八数码问题

百度一下可以百度出来很多介绍,在此简单说明一下八数码问题所要解决的东西是什么,即将一幅图分成3*3的格子其中八个是图一个空白,俗称拼图游戏=。=,我们需要求解的就是从一个散乱的状态到恢复原状最少需要多少步,以及每步怎么走。

我们可以抽象为现有数字0-8在九宫格中,0可以和其他数字交换。同时有一个开始状态和结束状态,现在需要求解出从初始到结束所需要的步数与过程。

解决思路

网上有很多算法可以解决八数码问题,本次我们采用最容易理解也是最简单的广度优先搜索(BFS),虽然是无序搜索并且浪费效率,不过我们还是先解决问题要紧,优化的方式大家可以接着百(谷)度(歌)一下。比如A*之类的,因为作者也不太会(逃。

广度优先搜索

原图来自JS 中的广度与深度优先遍历

原图来自JS 中的广度与深度优先遍历
这张图很好的展示了最基本的广度优先搜索的概念,即一层一层来遍历节点。在代码实现中我们需要按照上面图中1-12的顺序来遍历节点。实现方式可以为维护一个先入先出的队列Queue,按顺序将一层的节点从队尾推入,之后从从队头取出。当某个节点存在子节点,则将子节点推入队列的队尾,这样就可以保证子节点均会排在上层节点的后面。

结合八数码与广度优先搜索

现在我们已知广搜的相关概念,那么如何结合到八数码问题中呢?

  1. 首先我们需要将八数码中即0-8这九个数的每一种组合当做一种状态,那么按照排列组合定理我们可以求出八数码可能存在的状态数:9!即362880种排列组合。
  2. 对八数码的每种状态转换为代码中的表达方式,在此作者使用的是通过二维数组的形式,在文章的开头的配置方式中就可以看到初始与最终状态的二维数组表示。
  3. 为什么选择二维数组?因为对于0的移动限定是有一定空间边界的,比如0如果在第二行的最右边,那么0只能进行左上下三种移动方式。通过二维数组的两种下标可以很方便的来判断下一个状态的可选方向。
  4. 将每种状态转化为二维数组后,就可以配合广搜来进行遍历。初始状态可以设定为广搜中图的第一层,由初始状态通过判断0的移动方向可以得到不大于4中状态的子节点,同时需要维护一个对象来记录每个子节点的父节点是谁以此来反推出动画的运动轨迹及一个对象来负责判断当前子节点先前是否已出现过,出现过则无需再压入队。至此反复求出节点的子节点并无重复的压入队。
  5. 在遍历状态的过程中,可以将二维数组转化为数字或字符串,如123456780。在变为一维数组后便可以直接判断该状态是否等于最终状态,因为从数组变为了字符串或数字的基本类型就可以直接比较是否相等。如果相等那么从该节点一步步反推父节点至起始节点,得到动画路径。
  6. 在页面中通过动画路径生成动画。

当你明白了思想之后,我们将其转化为代码思路既可以表示为如下步骤:

  1. 初始节点压入队。
  2. 初始节点状态计入哈希表中。
  3. 出队,访问节点。
  4. 创建节点的子结点,检查是否与结束状态相同。若是,搜索结束,若否,检查哈希表是否存在此状态。若已有此状态,跳过,若无,把此结点压入队。
  5. 重复3,4步骤,即可得解。
  6. 根据目标状态结点回溯其父节点,可以得到完整的路径。
  7. 通过路径生成动画

看起来一切都很美好是不是?但是我们仍然忽略了一个问题,很关键。

八数码的可解性问题

如果真的像拼图一样,从一个已知状态打散到另一个状态,那么肯定是可以复原的。但是我们现在的配置策略是任意的,从而我们需要判断起始状态是否可以达到结束状态。判断方式是通过起始状态和结束状态的逆序数是否同奇偶来判断

逆序数:在一个排列中,如果一对数的前后位置与大小顺序相反,即前面的数大于后面的数,那么它们就称为一个逆序。一个排列中逆序的总数就称为这个排列的逆序数。一个排列中所有逆序总数叫做这个排列的逆序数。

如果起始状态与结束状态的逆序数的奇偶性相同,则说明状态可达,反之亦然。至于为什么,作者尝试通过简单的例子来试图说明并推广到整个结论:

//起始状态为[[1,2,3],[4,5,6],[7,8,0]]
//可以看做字符串123456780
//结束状态为[[1,2,3],[4,5,6],[7,0,8]]
//可以看做字符串123456708

这个变换只需要一步,即0向左与8进行交换。那么对于逆序数而言,0所在的位置是无关紧要的,因为它比谁都小,不会导致位置变化逆序数改变。所以0的横向移动不会改变逆序数的奇偶性。

//起始状态为[[1,2,3],[4,5,6],[7,8,0]]
//可以看做字符串123456780
//结束状态为[[1,2,3],[4,5,0],[7,8,6]]
//可以看做字符串123450786

这个变换同样只需要一步,即0向上与6进行交换。我们已知0的位置不会影响逆序数的值。那么现在我们只需要关注6的变化。6从第6位置变为第9位置,导致7与8所在位置之前的逆序数量出现了变化。7、8都比6大,则整体逆序数量会减少2,但是逆序数-2仍然保持了奇偶性。与此同时我们可以知道,当0纵向移动的时候,中间的两个数(当前例子7、8的位置)只会有三种情况。要不都比被交换数大(比如7、8比6大)要不一个大一个小,要不都小。如果一大一小,则逆序数仍会保持不变,因为总量上会是+1-1;都小的话则逆序数会+2,奇偶性同样不受到影响。故我们可以认为,0的横向与纵向移动并不会改变逆序数的奇偶性。从而我们可以在一开始通过两个状态的逆序数的奇偶性来判断是否可达。

核心代码

判断可解性

EightPuzzles.prototype.isCanMoveToEnd = function(startNode, endNode) {
    startNode = startNode.toString().split(',')
    endNode = endNode.toString().split(',')
    if(this.calParity(startNode) === this.calParity(endNode)) {
        return true 
    } else {
        return false
    }
}
EightPuzzles.prototype.calParity = function(node) {
    var num = 0
    console.log(node)
    node.forEach(function(item, index) {
        for(var i = 0; i < index; i++) {
            if(node[i] != 0) {
                if (node[i] < item) {
                    num++
                } 
            }
        }
    })
    if(num % 2) {
        return 1
    } else {
        return 0
    }
}

广度优先搜索

EightPuzzles.prototype.solveEightPuzzles = function() {
    if(this.isCanMoveToEnd(this.startNode, this.endNode)) {
        var _ = this
        this.queue.push(this.startNode)
        this.hash[this.startNodeStr] = this.startNode
        while(!this.isFind) { 
            var currentNode = this.queue.shift(),
                currentNodeStr = currentNode.toString().split(',').join('') //二维数组变为字符串
            if(_.endNodeStr === currentNodeStr) { //找到结束状态
                var path = []; // 用于保存路径
                var pathLength = 0
                var resultPath = []
                for (var v = _.endNodeStr; v != _.startNodeStr; v = _.prevVertx[v]) {
                    path.push(_.hash[v]) // 顶点添加进路径
                }
                path.push(_.hash[_.startNodeStr])
                pathLength = path.length
                for(var i = 0; i < pathLength; i++) {
                    resultPath.push(path.pop())
                }
                setTimeout(function(){
                    _.showDomMove(resultPath)
                }, 500)
                _.isFind = true
                return
            }
            result = this.getChildNodes(currentNode) //获得节点子节点
            result.forEach(function (item, i) {
                var itemStr = item.toString().split(',').join('')
                if (!_.hash[itemStr]) { //判断是否已存在该节点
                    _.queue.push(item)
                    _.hash[itemStr] = item
                    _.prevVertx[itemStr] = currentNodeStr //记录节点的父节点
                }
                
            })
        }
    } else {
        console.log('无法进行变换得到结果')
    }
    
}

生成动画

EightPuzzles.prototype.calDom = function(node) { //根据当前状态渲染各数字位置
    node.forEach(function(item, index) {
        item.forEach(function(obj, i) {
            $('#' + obj).css({left: i * (100+2), top: index* (100 + 2)})
        })
    })
}
EightPuzzles.prototype.showDomMove = function(path) {
    var _ = this
    path.forEach(function(item, index) { //每次状态改变调用一次渲染函数
        setTimeout(function(node) {
            this.calDom(node)
        }.bind(_, item), index * _.timer)
    })
}

参考文章

最后

惯例po作者的博客,不定时更新中——

有问题欢迎在issues下交流。