基於JavaScript求解八數碼最短路徑並生成動畫效果

寫在最前

本次分享一下經過廣度優先搜索解決八數碼問題並展現其最短路徑的動畫效果。css

歡迎關注個人博客,不按期更新中——html

效果預覽

該效果爲從[[2, 6, 3],[4, 8, 0],[7, 1, 5]] ==> [[[1, 2, 3],[4, 5, 6],[7, 8, 0]]]的效果展現node

2018-01-28 20_50_42

源碼地址git

配置方式以下:github

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能夠和其餘數字交換。同時有一個開始狀態和結束狀態,如今須要求解出從初始到結束所須要的步數與過程。canvas

解決思路

網上有不少算法能夠解決八數碼問題,本次咱們採用最容易理解也是最簡單的廣度優先搜索(BFS),雖然是無序搜索而且浪費效率,不過咱們仍是先解決問題要緊,優化的方式你們能夠接着百(谷)度(歌)一下。好比A*之類的,由於做者也不太會(逃。數組

廣度優先搜索

原圖來自JS 中的廣度與深度優先遍歷
<center>原圖來自JS 中的廣度與深度優先遍歷</center >
這張圖很好的展現了最基本的廣度優先搜索的概念,即一層一層來遍歷節點。在代碼實現中咱們須要按照上面圖中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所在位置以前的逆序數量出現了變化。七、8都比6大,則總體逆序數量會減小2,可是逆序數-2仍然保持了奇偶性。與此同時咱們能夠知道,當0縱向移動的時候,中間的兩個數(當前例子七、8的位置)只會有三種狀況。要不都比被交換數大(好比七、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下交流。

相關文章
相關標籤/搜索