用 javascript 編寫基于 HTML 的數(shù)字推盤游戲的 AI

本文翻譯自 JavaScript AI For An HTML Sliding Tiles Puzzle By Arnaldo Perez Castano 苔悦。

Sam Loud (1841 - 1911) 美國國際象棋手枫疆,智力游戲設(shè)計(jì)師,在1870年代發(fā)明了數(shù)字推盤游戲送膳。這個(gè)游戲由 m 行 n 列的網(wǎng)格組成揖膜,每個(gè)格子可以是任何有規(guī)律的事物疮蹦,比如數(shù)字惰瓜,字母搪缨,圖片等。

游戲的解答過程是將一個(gè)排布變成另一個(gè)排布鸵熟,即從初始狀態(tài)到目標(biāo)狀態(tài)。重新排序的方式是將空的格子和它邊上的格子通過上下左右四個(gè)方向的交換來完成的负甸。

1.jpg

空的格子不能被移除出邊框流强,因此如果它在第一列的話,空格不能往左移動;如果它在最右邊的一列的話,它就不能再往右移動了。行的規(guī)定也是類似的。解的過程是這樣的:

2.jpg

這是開局夕吻。

3.jpg

這樣就算解出了。

只要驗(yàn)證左邊的排布和右邊的目標(biāo)排布是一樣的就可以判斷是否完成了默勾。

本文將分兩個(gè)部分习霹。第一部分會簡要解釋怎么編寫一個(gè)推盤游戲煞檩,用 html, css 實(shí)現(xiàn)顯示部分,然后用 javascript 來移動盤上的滑塊陶衅。這部分同時(shí)也用在下一部分的文章中。

文章的第二部分,我們會用 A* 算法來開發(fā)一個(gè)人工智能慨丐,用來解決推盤游戲,計(jì)算解答游戲的最小步數(shù)瞪讼。A* 算法的多種啟發(fā)式會給尋找解法帶來很多幫助,啟發(fā)式越智能嫡霞,那么就能越快找到最優(yōu)解法。啟發(fā)式會按照智能程度由低到高,因此最后的啟發(fā)式將是最強(qiáng)大的。

布局

首先我們創(chuàng)建一個(gè)空項(xiàng)目抡蛙,包含一個(gè) html 文件和一個(gè) css 文件。分別是: index.htmlindex.css 极祸。

引入 jquery.js 為了讓生活更美好慈格,寫出更加簡潔優(yōu)雅的代碼。(譯者注:此處的文件名按照本人習(xí)慣稍作改動)

html 文件的頭部如下:

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
    <link type="image/png" rel="shortcut icon" href="/vivaxy.icon.png">
    <link type="text/css" rel="stylesheet" href="index.css">
    <title>Sliding Tiles Puzzle</title>
</head>

為了頁面的性能遥金,我們把 js 都放在頁面底端浴捆。這是常見的做法,因?yàn)轫撁驿秩臼怯缮现料碌母逍担覀兿M撁嫔系脑叵蕊@示选泻,因此把用來交互的 js 文件放在頁面最下方。

<body>

<script type="text/javascript" src="js/jquery.js"></script>
<script type="text/javascript" src="js/priority-queue.js"></script>
<script type="text/javascript" src="js/hash-table.js"></script>
<script type="text/javascript" src="js/hash-set.js"></script>
<script type="text/javascript" src="js/index.js"></script>
</body>

priority-queue.js, hash-table.js, hash-set.js 是用來編寫人工智能的美莫,分別是用來存放隊(duì)列页眯,哈希表和哈希對的。

現(xiàn)在我們開始寫 html 上的布局厢呵。首先窝撵,布局的框架是這樣的。

<div class="container"></div>
<div class="panel"></div>

container 容器這個(gè)類選擇器在 index.css 文件中是這樣的

/*
Developed by Arnaldo Perez Castano
arnaldo.skywalker@gmail.com
*/
.container {
    width: 1024px;
    margin-left: auto;
    margin-right: auto;
    min-height: 380px;
}

面板是在頁面上打印顯示人工智能的日志和結(jié)果的襟铭。

.panel {
    width: 100%;
    background-color: rgb(180, 180, 180);
    min-height: 1000px;
    color: white;
    font-weight: bold;
    padding: 5px;
    font-family: Arial;
}

我們希望容器在頁面中間碌奉,所以需要給它設(shè)置寬度短曾,并且把它的 margin-leftmargin-right 設(shè)置為 auto

現(xiàn)在我們在 container 中加入 grid-container 赐劣,用來顯示推盤的網(wǎng)格嫉拐。

<div class="container">
    <div class="grid-container">
        <h2> Initial Config </h2>

    </div>
</div>

grid-container 和它下面的元素的樣式如下所示。

.grid-container {
    float: left;
    height: 250px;
    text-align: center;
    width: 50%;
}

.grid-container h2 {
    font-family: Tahoma;
}

我們把推盤容器左浮動魁兼,因?yàn)閮蓚€(gè)同樣的容器要顯示在一行婉徘,一個(gè)是用來顯示初始布局的,另一個(gè)是用來顯示目標(biāo)布局的咐汞。

最后盖呼,我們的推盤是這個(gè)樣子的。

<div class="grid-container">
    <h2> Initial Config </h2>
    <div class="grid start">
        <div class="row">
            <div class="cell" data-pos="0,0"><span>6</span></div>
            <div class="cell" data-pos="0,1"><span>4</span></div>
            <div class="cell" data-pos="0,2"><span>7</span></div>
        </div>
        <div class="row">
            <div class="cell" data-pos="1,0"><span>8</span></div>
            <div class="cell" data-pos="1,1"><span>5</span></div>
            <div class="cell" id="empty" data-pos="1,2"></div>
        </div>
        <div class="row">
            <div class="cell" data-pos="2,0"><span>3</span></div>
            <div class="cell" data-pos="2,1"><span>2</span></div>
            <div class="cell" data-pos="2,2"><span>1</span></div>
        </div>
    </div>
</div>

推盤游戲有三行碉考,每行有三個(gè)滑塊塌计,這就組成了整個(gè)推盤網(wǎng)格。于是我們使用了以上布局侯谁,三個(gè)行容器锌仅,每個(gè)行容器有三個(gè)子元素,每個(gè)子元素是一個(gè)滑塊墙贱。

為了編程方便热芹,我們在每個(gè)子元素上添加了 data-pos 這個(gè)屬性,為了記錄每個(gè)滑塊在推盤中的位置惨撇。 start 這個(gè)類名是為了區(qū)分初始布局的推盤和目標(biāo)布局的推盤伊脓,后者不會綁定用戶操作。以上幾個(gè)類的樣式如下魁衙。

.grid {
    background-color: rgb(248, 248, 248);
    border: solid 5px rgb(249, 90, 0);
    width: 210px;
    height: 210px;
    margin-left: auto;
    margin-right: auto;
    border-radius: 3px;
    box-shadow: 5px 5px #d8d8d8, 5px 5px #d8d8d8;
    overflow: auto;
}

.row {
    height: 33.3%;
}

.cell {
    width: 33.3%;
    height: 100%;
    float: left;
    text-align: center;
    font-size: 150%;
    font-family: Arial;
    font-weight: bold;
    position: relative;
}

.cell:hover {
    background-color: rgb(221, 221, 221);
}

.cell span {
    display: block;
    transform: translateY(70%);
}

最后結(jié)果如下

4.jpg

接下來我們把目標(biāo)布局放上去报腔,我們復(fù)制一下起始布局,把上面的 start 改成 goal 就行了剖淀。

<div class="grid-container">
    <h2> Goal Config </h2>
    <div class="grid goal">
        <div class="row">
            <div class="cell"><span>1</span></div>
            <div class="cell"><span>2</span></div>
            <div class="cell"><span>3</span></div>
        </div>
        <div class="row">
            <div class="cell"><span>4</span></div>
            <div class="cell"><span>5</span></div>
            <div class="cell"><span>6</span></div>
        </div>
        <div class="row">
            <div class="cell"><span>7</span></div>
            <div class="cell"><span>8</span></div>
            <div class="cell"></div>
        </div>
    </div>
</div>
5.jpg

最后纯蛾,我們把 SolveShow Step 這兩個(gè)按鈕加到第一個(gè)布局的容器中。

<button onclick="start()"> Solve </button>
<button onclick="showSolution()"> Show Step </button>

第一個(gè)按鈕會執(zhí)行自動計(jì)算纵隔,也就是 A* 算法翻诉。第二個(gè)按鈕會顯示自動計(jì)算的一步。所以捌刮,按第二個(gè)按鈕多次之后我們就得到這個(gè)推盤游戲的最優(yōu)解法了碰煌。

我們已經(jīng)把所有界面展示部分完成了,那么我們開始寫一些功能模塊的部分绅作。我們現(xiàn)在要讓這個(gè)游戲能動起來嫡丙,其實(shí)就是要讓空白的滑塊能夠在整個(gè)推盤中移動摔刁。我們用 javascript 來實(shí)現(xiàn)這個(gè)功能愚隧。 index.js 文件的前幾行如下:

/*
   Developed by Arnaldo Perez Castano
   arnaldo.skywalker@gmail.com
*/

var emptytilePosRow = 1;
var emptytilePosCol = 2;
var cellDisplacement = '69px';

emptytilePosRowemptytilePosCol 這兩個(gè)變量會保存空白滑塊所處的位置。每次移動的時(shí)候碍脏,都會改變。

cellDisplacement 這個(gè)變量用來記錄這個(gè)滑塊在動畫執(zhí)行過程中所移動的距離稍算。cell 這個(gè)類所在的 divpositionrelative 。所以我們用 topright 這兩個(gè)屬性來實(shí)現(xiàn)動畫效果役拴。 cellDisplacement 變量會記錄下 topright 的最新的值糊探,來實(shí)現(xiàn)滑塊的移動。

用來實(shí)現(xiàn)移動的方法如下:

var moveTile = function (e) {
    // Gets the position of the current element
    var pos = $(this).attr('data-pos');
    var posRow = parseInt(pos.split(',')[0]);
    var posCol = parseInt(pos.split(',')[1]);
    // ...
};

請看河闰,我們已經(jīng)用上 jquery 來選擇元素了科平。還要注意我們要用 start 這個(gè)類來讓我們選擇的都是初始布局中的元素,來保證目標(biāo)布局不變姜性。然后瞪慧,我們拿到選中的滑塊的位置。位置是用 x,y 這種形式存的部念。然后我們拿到了行和列的值弃酌,存在 posRowposCol 這兩個(gè)變量中。

接下來的代碼是用來執(zhí)行移動的儡炼。

// Move Up
if (posRow + 1 == emptytilePosRow && posCol == emptytilePosCol) {
    $(this).animate({
        'top': "+=" + cellDisplacement //moves up
    });

    $('#empty').animate({
        'top': "-=" + cellDisplacement //moves down
    });

    emptytilePosRow -= 1;
    $(this).attr('data-pos', (posRow + 1) + "," + posCol);
}

// Move Down
if (posRow - 1 == emptytilePosRow && posCol == emptytilePosCol) {
    $(this).animate({
        'top': "-=" + cellDisplacement //moves down
    });

    $('#empty').animate({
        'top': "+=" + cellDisplacement //moves up
    });

    emptytilePosRow += 1;
    $(this).attr('data-pos', (posRow - 1) + "," + posCol);
}

// Move Left
if (posRow == emptytilePosRow && posCol + 1 == emptytilePosCol) {
    $(this).animate({
        'right': "-=" + cellDisplacement //moves right
    });

    $('#empty').animate({
        'right': "+=" + cellDisplacement //moves left
    });

    emptytilePosCol -= 1;
    $(this).attr('data-pos', posRow + "," + (posCol + 1));
}

// Move Right
if (posRow == emptytilePosRow && posCol - 1 == emptytilePosCol) {
    $(this).animate({
        'right': "+=" + cellDisplacement //moves left
    });

    $('#empty').animate({
        'right': "-=" + cellDisplacement //moves right
    });

    emptytilePosCol += 1;
    $(this).attr('data-pos', posRow + "," + (posCol - 1));
}

// Update empty position
$('#empty').attr('data-pos', emptytilePosRow + "," + emptytilePosCol);

每個(gè) if 語句表示不同的移動方向妓湘。他們開起來差不多,只是條件乌询,移動方向和更新的變量不同而已榜贴。比如向右移動,要看空的滑塊是不是在當(dāng)前移動的滑塊的左邊:posRow === emptytilePosRow 表示在一行妹田,posCol - 1 === emptytileCol 表示空白的滑塊在當(dāng)前移動的滑塊的左邊一列唬党。

如果條件滿足,則執(zhí)行 jquery 的動畫鬼佣,我們改變 right 屬性的值驶拱,來移動當(dāng)前選中的滑塊。if 的條件語句的最后沮趣,我們修改 emptytilePosCol 的值屯烦,在其原來的值上加 1 ,因?yàn)榭瞻椎幕瑝K向右移動了一格房铭。同時(shí)我們修改當(dāng)前移動的滑塊的位置的值驻龟,列數(shù)減1。最后我們改變空白滑塊的位置缸匪。

人工智能

A* 搜尋算法( Hart 等人在 1968 年提出)是用來計(jì)算多節(jié)點(diǎn)路徑中兩點(diǎn)之間最短距離的算法翁狐。我們用它來開發(fā)解答滑塊游戲的功能。一個(gè)人工智能是能夠在某種環(huán)境下按照特定的規(guī)則完成特定的任務(wù)的一種工具凌蔬。最終的解法將有人工執(zhí)行這個(gè)工具通過正確的決策來尋找露懒。

人類在大部分情況下是理性的闯冷。一個(gè)人會根據(jù)所處環(huán)境的不同,從環(huán)境中得到信息懈词,來對特定的情況采取理性的行為蛇耀。比如在寒冷的冬天,我們會感到寒冷坎弯,于是我們會穿上厚厚的外套纺涤。

在滑塊游戲這個(gè)場景下,環(huán)境是由整個(gè)板子決定的抠忘×么叮滑塊游戲的規(guī)則決定某個(gè)滑塊能夠向某個(gè)方向移動。如果該搜尋算法是有效的話崎脉,那么在一定的移動步數(shù)后拧咳,滑塊的布局會變成目標(biāo)布局。

A* 搜尋算法能做什么

A* 搜尋算法能夠找到從一種空間布局到另一種空間布局轉(zhuǎn)換方法囚灼。搜尋是否智能是根據(jù)轉(zhuǎn)換過程中操作的步數(shù)來判斷的骆膝,步數(shù)越少,算法越智能啦撮。為了能方便描述布局狀態(tài)谭网,我們把問題轉(zhuǎn)換成圖形。我們認(rèn)為狀態(tài) B 是狀態(tài) A 的下一種狀態(tài)赃春,也就是說要到狀態(tài) B 就要先到狀態(tài) A 愉择,由狀態(tài) A 通過移動一個(gè)滑塊就能夠到達(dá)狀態(tài) B 了。所以一個(gè)狀態(tài)節(jié)點(diǎn)會有如下的四中子狀態(tài)節(jié)點(diǎn)织中。

6.jpg

A* 算法下節(jié)點(diǎn)示意圖

A* 搜尋算法基本思路是根據(jù)外界環(huán)境來選擇下一步的行為锥涕。外界環(huán)境是由數(shù)字組成的。假設(shè)狀態(tài)為 s 狭吼,那么

f(s) = g(s) + h(s)

其中 g(s) 是從初始狀態(tài)到狀態(tài) s 的總步數(shù)层坠,h(s) 是從狀態(tài) s 到目標(biāo)狀態(tài)的總步數(shù),所以總步數(shù)是 f(s) 刁笙。

7.jpg

從其實(shí)狀態(tài)到目標(biāo)狀態(tài)的總步數(shù)

我們使用啟發(fā)式來尋找這巨大可能中的情況破花。啟發(fā)式是一種載體,我們可以通過它將我們的經(jīng)驗(yàn)和特定環(huán)境傳遞到人工智能算法中去疲吸,也就是傳遞到 A* 算法中去座每。通過啟發(fā)式,我們就能找到解答問題的最短的途徑了摘悴。

既然我們已經(jīng)把問題轉(zhuǎn)換成圖像了峭梳,那么 A* 算法的大概框架就變得和 BFS 算法(廣度優(yōu)先算法)差不多了。BFS 算法是一種經(jīng)典的圖形節(jié)點(diǎn)算法蹂喻。 A* 算法和 BFS 算法的區(qū)別在于葱椭, A* 算法中下一環(huán)節(jié)開始計(jì)算的節(jié)點(diǎn)的選擇是和 f(s) 的值關(guān)聯(lián)的捂寿,會優(yōu)先選擇最短的 f(s) 來優(yōu)先計(jì)算 ,但是在 BFS 算法中孵运,所有節(jié)點(diǎn)的選擇權(quán)重都是 1 秦陋,所以哪個(gè)路徑更短并沒有任何影響,在 BFS 算法中治笨,下一環(huán)節(jié)的計(jì)算會從先記錄的節(jié)點(diǎn)開始踱侣,也就是先進(jìn)先出(FIFO)的隊(duì)列原則。

我們建立啟發(fā)式的時(shí)候大磺,一定要保證其中包含了所有關(guān)鍵信息。不知道這段是什么意思探膊。不過大概說了要有個(gè)條件讓 A* 算法能找到最優(yōu)解杠愧,類似于函數(shù)收斂則能找到極值。

前面說到我們要用 javascript 來寫人工智能逞壁,有些人大概會認(rèn)為這是不明智的流济,不過后面我會證明 javascript 擁有足夠的能力。我們先寫一個(gè) Node 對象腌闯。

var Node = function (value, state, emptyRow, emptyCol, depth) {
    this.value = value;
    this.state = state;
    this.emptyCol = emptyCol;
    this.emptyRow = emptyRow;
    this.depth = depth;
    this.strRepresentation = '';
    this.path = '';

    // String representation of the state in CSV format
    for (var i = 0; i < state.length; i++) {
        // We assume the state is a square
        if (state[i].length !== state.length) {
            alert('Number of rows differs from number of columns');
            return;
        }

        for (var j = 0; j < state[i].length; j++) {
            this.strRepresentation += state[i][j] + ',';
        }
    }
    this.size = this.state.length;
};

每個(gè)變量的說明如下:

  • value f(s) 的值

  • state 用二維數(shù)組保存滑塊排布的狀態(tài)

  • emptyCol 記錄空滑塊所在的列

  • emptyRow 記錄空滑塊所在的行

  • depth 記錄從起始狀態(tài)到現(xiàn)在的步數(shù)

  • strRepresentation CSV 字符串的形式保存滑塊布局的狀態(tài)绳瘟。比如目標(biāo)布局的值是 1,2,3,4,5,6,7,8 ∽丝ィ滑塊游戲布局是一個(gè)可以循環(huán)的游戲糖声,也就是說從狀態(tài) s 經(jīng)過一定的移動可以回到狀態(tài) s 。因此我們需要記錄下每步的布局來避免走重復(fù)的路分瘦。這里我們會用 HashSet 蘸泻。

  • path 記錄每步的移動,用 DLRU 嘲玫。這個(gè)字符串保存的是從其實(shí)布局到當(dāng)前布局走過的路悦施。

  • size 滑塊游戲推盤的大小,我們假設(shè)推盤是 n 乘以 m 的去团,n 等于 m 抡诞。

現(xiàn)在我們有了 Node 這個(gè)對象,我們來用個(gè)例子演示一下使用 A* 算法解題的過程土陪。我們用最簡單的啟發(fā)式昼汗,根據(jù)放錯(cuò)位置的滑塊的個(gè)數(shù)來。錯(cuò)位啟發(fā)式返回的值就是不在自己應(yīng)該在的位置的滑塊的個(gè)數(shù)旺坠。這里說明了這個(gè)啟發(fā)式是可接受的乔遮。

8.jpg

A* 搜尋算法

下面我們來實(shí)現(xiàn) A* 算法:

var AStar = function (initial, goal, empty) {
    this.initial = initial;
    this.goal = goal;
    this.empty = empty;
    this.queue = new PriorityQueue({
        comparator: function (a, b) {
            if (a.value > b.value) {
                return 1;
            }
            if (a.value < b.value) {
                return -1;
            }
            return 0;
        }
    });
    this.queue.queue(initial);
    this.visited = new HashSet();
};

上面我們以及功能使用了之前定義的數(shù)據(jù)結(jié)構(gòu)。優(yōu)先隊(duì)列中我們定義了一種排序方法取刃,我們會把 f(s) 較小的狀態(tài)放在前面蹋肮。哈希對中存放 strRepresentation 記錄所有到達(dá)過的狀態(tài),來避免重復(fù)坯辩。

現(xiàn)在我們用原型鏈給 A* 算法添加方法馁龟。 原型鏈 prototype 是一種方法或者屬性,在創(chuàng)建新對象實(shí)例時(shí)漆魔,這個(gè)方法或者屬性會成為新實(shí)例的一部分坷檩。比如, execute 這個(gè)方法會在所有 AStar 對象中存在改抡。

AStar.prototype.execute = function () {
    // Add current state to visited list
    this.visited.add(this.initial.strRepresentation);

    while (this.queue.length > 0) {
        var current = this.queue.dequeue();

        if (current.strRepresentation === this.goal.strRepresentation) {
            return current;
        }

        this.expandNode(current);
    }
};

execute 方法在以下幾點(diǎn)上像 BFS :

  • 有循環(huán)矢炼,循環(huán)結(jié)束于優(yōu)先隊(duì)列結(jié)束后。

  • 當(dāng)前的變量存放的是隊(duì)列中的最小值阿纤。

  • 如果節(jié)點(diǎn)的狀態(tài)與目標(biāo)狀態(tài)匹配的話句灌,那么我們認(rèn)為整個(gè)搜尋任務(wù)結(jié)束。

  • 如果搜尋任務(wù)沒有結(jié)束欠拾,那么我們會對當(dāng)前的節(jié)點(diǎn)進(jìn)行展開胰锌。也就是在當(dāng)點(diǎn)狀態(tài)下執(zhí)行每個(gè)方向的移動,然后把新的節(jié)點(diǎn)添加到隊(duì)列中藐窄。

對節(jié)點(diǎn)展開的方法如下:

AStar.prototype.expandNode = function (node) {
    var temp = '';
    var newState = '';
    var col = node.emptyCol;
    var row = node.emptyRow;
    var newNode = '';

    // Up
    if (row > 0) {
        newState = node.state.clone();
        temp = newState[row - 1][col];
        newState[row - 1][col] = this.empty;
        newState[row][col] = temp;
        newNode = new Node(0, newState, row - 1, col, node.depth + 1);

        if (!this.visited.contains(newNode.strRepresentation)) {
            newNode.value = newNode.depth + this.heuristic(newNode);
            newNode.path = node.path + 'U';
            this.queue.queue(newNode);
            this.visited.add(newNode.strRepresentation);
        }
    }

    // Down
    if (row < node.size - 1) {
        newState = node.state.clone();
        temp = newState[row + 1][col];
        newState[row + 1][col] = this.empty;
        newState[row][col] = temp;
        newNode = new Node(0, newState, row + 1, col, node.depth + 1);

        if (!this.visited.contains(newNode.strRepresentation)) {
            newNode.value = newNode.depth + this.heuristic(newNode);
            newNode.path = node.path + 'D';
            this.queue.queue(newNode);
            this.visited.add(newNode.strRepresentation);
        }
    }

    // Left
    if (col > 0) {
        newState = node.state.clone();
        temp = newState[row][col - 1];
        newState[row][col - 1] = this.empty;
        newState[row][col] = temp;
        newNode = new Node(0, newState, row, col - 1, node.depth + 1);

        if (!this.visited.contains(newNode.strRepresentation)) {
            newNode.value = newNode.depth + this.heuristic(newNode);
            newNode.path = node.path + 'L';
            this.queue.queue(newNode);
            this.visited.add(newNode.strRepresentation);
        }
    }

    // Right
    if (col < node.size - 1) {
        newState = node.state.clone();
        temp = newState[row][col + 1];
        newState[row][col + 1] = this.empty;
        newState[row][col] = temp;
        newNode = new Node(0, newState, row, col + 1, node.depth + 1);

        if (!this.visited.contains(newNode.strRepresentation)) {
            newNode.value = newNode.depth + this.heuristic(newNode);
            newNode.path = node.path + 'R';
            this.queue.queue(newNode);
            this.visited.add(newNode.strRepresentation);
        }
    }
};

這里的 if 條件語句都很像资昧,差別只是他們針對的移動方向不同。首先荆忍,我們判斷一下這步是否能夠正常進(jìn)行格带。比如向右移動,只有空滑塊的列數(shù)小于推盤的行數(shù)才行刹枉。如果這步能夠正常移動践惑,那么我們建立一個(gè)新的狀態(tài) newState ,這個(gè)狀態(tài)是從當(dāng)前狀態(tài)復(fù)制出來的嘶卧。然后我們交換空白滑塊和對應(yīng)元素尔觉,同時(shí)修改 newState ,最后我們判斷一下這個(gè)狀態(tài)是不是已經(jīng)存在了芥吟,如果沒有存在的話侦铜,把這個(gè)狀態(tài)記錄到隊(duì)列中去。我們還要計(jì)算一下節(jié)點(diǎn)的值钟鸵,按照之間提到過的 f = g + h 钉稍,然后在 path 變量中記錄下移動的方向。

Array.prototype.clone = function () {
    return JSON.parse(JSON.stringify(this));
};

最后別忘了啟發(fā)式方法

AStar.prototype.heuristic = function (node) {
    return this.manhattanDistance(node);
};

這里開始棺耍,我們就能比較不用啟發(fā)式下的 A* 算法的效率了贡未。我們會慢慢發(fā)現(xiàn)一個(gè)啟發(fā)式對于算法來說是至關(guān)重要的,其智能程度極大地影響了算法的耗時(shí)。

錯(cuò)位滑塊

在我們進(jìn)入有趣的啟發(fā)式之前俊卤,先記得在計(jì)算啟發(fā)式的時(shí)候嫩挤,我們絕對不會考慮空白滑塊。否則我們就會過高估路徑的步數(shù)了消恍,那么啟發(fā)式可能不能被接受了岂昭。比如下面這個(gè)情況:

9.jpg

如果們我考慮空白滑塊,那么 h 就是 2 了狠怨,而事實(shí)上约啊,我們只要把空白滑塊向下移動一步就行了。因此實(shí)際的最短步數(shù)是 1 佣赖,而不是二恰矩,我們高估路徑的步數(shù)了。

為了測試我們的啟發(fā)式憎蛤,我們用一個(gè)最差情況來試一試枢里,這個(gè)情況下需要 31 步才能完成解答。

10.jpg

A* 算法在點(diǎn)擊 Solve 按鈕后會執(zhí)行蹂午。onclick 觸發(fā)的事件是執(zhí)行 start 方法,方法的內(nèi)容如下:

var start = function () {
    var init = new Node(0, [[6, 4, 7], [8, 5, 0], [3, 2, 1]], 1, 2, 0);
    var goal = new Node(0, [[1, 2, 3], [4, 5, 6], [7, 8, 0]], 2, 2, 0);

    var aStar = new AStar(init, goal, 0);
    // To measure time taken by the algorithm
    var startTime = new Date();
    // Execute AStar
    var result = aStar.execute();
    // To measure time taken by the algorithm
    var endTime = new Date();
    alert('Completed in: ' + (endTime - startTime) + ' milliseconds');

    var panel = document.getElementById('panel');
    panel.innerHTML = 'Solution: ' + result.path + ' Total steps: ' + result.path.length + '';
    solution = result.path;
};

我們用毫秒來記錄算法的耗時(shí)彬碱。用這種方式來評估不同啟發(fā)式之間的優(yōu)劣豆胸。錯(cuò)位滑塊啟發(fā)式的代碼如下:

AStar.prototype.misplacedTiles = function (node) {
    var result = 0;

    for (var i = 0; i < node.state.length; i++) {
        for (var j = 0; j < node.state[i].length; j++) {
            if (node.state[i][j] != this.goal.state[i][j] && node.state[i][j] != this.empty) {
                result++;
            }
        }
    }

    return result;
};

執(zhí)行結(jié)果如下:

11.jpg

算法花了將近 4 秒來解答,誒喲巷疼,不錯(cuò)喲晚胡。但是我們可以用一個(gè)更加棒的啟發(fā)式來讓計(jì)算更快。

曼哈頓距離

曼哈頓距離或者塊距離是用來計(jì)算兩點(diǎn)間距離的絕對值的嚼沿。

MD = | x1 - x2 | + | y1 - y2 |

A 點(diǎn) (x1, y1) 和 B 點(diǎn) (x2, y2) 之間的距離

這個(gè)結(jié)果是可接受的估盘,因?yàn)樗肋h(yuǎn)給出的是兩點(diǎn)之間最短的路徑。

AStar.prototype.manhattanDistance = function (node) {
    var result = 0;

    for (var i = 0; i < node.state.length; i++) {
        for (var j = 0; j < node.state[i].length; j++) {
            var elem = node.state[i][j];
            var found = false;
            for (var h = 0; h < this.goal.state.length; h++) {
                for (var k = 0; k < this.goal.state[h].length; k++) {
                    if (this.goal.state[h][k] == elem) {
                        result += Math.abs(h - i) + Math.abs(j - k);
                        found = true;
                        break;
                    }
                }
                if (found) {
                    break;
                }
            }
        }
    }

    return result;
};

使用了這個(gè)啟發(fā)式的結(jié)果如下:

12.jpg

我們已經(jīng)大大減少了計(jì)算時(shí)間骡尽,現(xiàn)在只要 1 秒不到遣妥。曼哈頓距離這個(gè)啟發(fā)式更加精確地估算了離目標(biāo)布局的步數(shù),因此我們可以更快地到達(dá)目的地攀细。

結(jié)合曼哈頓距離和線性沖突

盡管嗎哈頓距離大大縮小了算法的耗時(shí)箫踩,但是依然有很多優(yōu)化可以做。線性沖突啟發(fā)式提供了這個(gè)關(guān)鍵點(diǎn)谭贪。如果 tj 和 tk 這兩個(gè)是在用一條線上境钟,并且他們兩個(gè)的目的地都是在這條線上,并且 tj 要去 tk 那個(gè)方向俭识,tk 要去 tj 那個(gè)方向慨削,那么我們說 tj 和 tk 是線性沖突的。

13.jpg

左邊的布局,滑塊 3 和滑塊 1 在一行中缚态,但是不在正確的位置磁椒。為了讓他們能夠到正確的位置上去,我們必須把其中一塊向下移動猿规,再把它移上來衷快,這個(gè)移動的行為在曼哈頓距離啟發(fā)式中沒有考慮進(jìn)去。要注意姨俩,一個(gè)滑塊不可能和很多個(gè)滑塊產(chǎn)生線性沖突蘸拔,因?yàn)榻鉀Q了一個(gè)線性沖突后,也就解決了這一行的所有線性沖突了环葵。因此,如果滑塊 1 和滑塊 3 是線性沖突的菊卷,那么滑塊 1 和滑塊 2 就不是線性沖突的了洁闰,否則啟發(fā)式就變得不可接受,我們會高估解答的步數(shù)聘裁。線性沖突啟發(fā)式的代碼如下:


AStar.prototype.linearConflicts = function (node) {
    var result = 0;
    var state = node.state;

    // Row Conflicts
    for (var i = 0; i < state.length; i++) {
        result += this.findConflicts(state, i, 1)
    }

    // Column Conflicts
    for (var i = 0; i < state[0].length; i++) {
        result += this.findConflicts(state, i, 0)
    }

    return result;
};

AStar.prototype.findConflicts = function (state, i, dimension) {
    var result = 0;
    var tilesRelated = [];

    // Loop foreach pair of elements in the row/column
    for (var h = 0; h < state.length - 1 && tilesRelated.indexOf(h) === -1; h++) {
        for (var k = h + 1; k < state.length && tilesRelated.indexOf(h) === -1; k++) {
            var moves = dimension == 1
                ? this.inConflict(i, state[i][h], state[i][k], h, k, dimension)
                : this.inConflict(i, state[h][i], state[k][i], h, k, dimension);

            if (moves == 0) {
                continue;
            }
            result += 2;
            tilesRelated.push([h, k]);
            break;
        }
    }

    return result;
};

AStar.prototype.inConflict = function (index, a, b, indexA, indexB, dimension) {
    var indexGoalA = -1;
    var indexGoalB = -1;

    for (var c = 0; c < this.goal.state.length; c++) {
        if (dimension == 1 && this.goal.state[index][c] == a) {
            indexGoalA = c;
        } else if (dimension == 1 && this.goal.state[index][c] == b) {
            indexGoalB = c;
        } else if (dimension == 0 && this.goal.state[c][index] == a) {
            indexGoalA = c;
        } else if (dimension == 0 && this.goal.state[c][index] == b) {
            indexGoalB = c;
        }
    }

    return (indexGoalA >= 0 && indexGoalB >= 0) &&
    ((indexA < indexB && indexGoalA > indexGoalB) ||
    (indexA > indexB && indexGoalA < indexGoalB))
        ? 2
        : 0;
};

既然線性沖突啟發(fā)式和曼哈頓距離啟發(fā)式之間不會發(fā)生沖突捌显,那么我們可以把他們兩個(gè)結(jié)合起來摄闸,來獲得一個(gè)更加棒的算法乎完。

AStar.prototype.heuristic = function (node) {
    return this.manhattanDistance(node) + this.linearConflicts(node);
};

加入了線性沖突啟發(fā)式后,結(jié)果如下:

14.jpg

加入了線性沖突啟發(fā)式后转晰,我們在計(jì)算速度上又提高了很大一截。如果想看結(jié)果的話,我們可以看灰色的面板上顯示的內(nèi)容番官。

15.jpg

按了 Show Step 按鈕之后讶凉,我們可以看到解法的一步懂讯。按了 31 步之后台颠,我們就能看到解答的整個(gè)過程了实蔽。

16.jpg

這篇文章我們介紹了用人工智能 A* 算法解推盤游戲的方法。我們驗(yàn)證了不同啟發(fā)式下結(jié)果的正確性填具,并且成功地找到了一種非常有效的啟發(fā)式∮颍現(xiàn)在你可以用它來打敗你的朋友們了。我們體會了人工智能的神奇,我們用它來解決了生活中的問題筋量。人工智能的最終的目的是給我們帶來更加輕松而高效的生活。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末错览,一起剝皮案震驚了整個(gè)濱河市携冤,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌闲勺,老刑警劉巖翘地,帶你破解...
    沈念sama閱讀 221,576評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異癌幕,居然都是意外死亡衙耕,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,515評論 3 399
  • 文/潘曉璐 我一進(jìn)店門勺远,熙熙樓的掌柜王于貴愁眉苦臉地迎上來橙喘,“玉大人,你說我怎么就攤上這事胶逢√梗” “怎么了?”我有些...
    開封第一講書人閱讀 168,017評論 0 360
  • 文/不壞的土叔 我叫張陵初坠,是天一觀的道長和簸。 經(jīng)常有香客問我,道長碟刺,這世上最難降的妖魔是什么锁保? 我笑而不...
    開封第一講書人閱讀 59,626評論 1 296
  • 正文 為了忘掉前任,我火速辦了婚禮半沽,結(jié)果婚禮上爽柒,老公的妹妹穿的比我還像新娘。我一直安慰自己者填,他們只是感情好浩村,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,625評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著幔托,像睡著了一般穴亏。 火紅的嫁衣襯著肌膚如雪蜂挪。 梳的紋絲不亂的頭發(fā)上重挑,一...
    開封第一講書人閱讀 52,255評論 1 308
  • 那天,我揣著相機(jī)與錄音棠涮,去河邊找鬼谬哀。 笑死,一個(gè)胖子當(dāng)著我的面吹牛严肪,可吹牛的內(nèi)容都是我干的史煎。 我是一名探鬼主播谦屑,決...
    沈念sama閱讀 40,825評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼篇梭!你這毒婦竟也來了氢橙?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,729評論 0 276
  • 序言:老撾萬榮一對情侶失蹤恬偷,失蹤者是張志新(化名)和其女友劉穎悍手,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體袍患,經(jīng)...
    沈念sama閱讀 46,271評論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡坦康,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,363評論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了诡延。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片滞欠。...
    茶點(diǎn)故事閱讀 40,498評論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖肆良,靈堂內(nèi)的尸體忽然破棺而出筛璧,到底是詐尸還是另有隱情,我是刑警寧澤妖滔,帶...
    沈念sama閱讀 36,183評論 5 350
  • 正文 年R本政府宣布隧哮,位于F島的核電站,受9級特大地震影響座舍,放射性物質(zhì)發(fā)生泄漏沮翔。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,867評論 3 333
  • 文/蒙蒙 一曲秉、第九天 我趴在偏房一處隱蔽的房頂上張望采蚀。 院中可真熱鬧,春花似錦承二、人聲如沸榆鼠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,338評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽妆够。三九已至,卻和暖如春负蚊,著一層夾襖步出監(jiān)牢的瞬間神妹,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,458評論 1 272
  • 我被黑心中介騙來泰國打工家妆, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留鸵荠,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,906評論 3 376
  • 正文 我出身青樓伤极,卻偏偏與公主長得像蛹找,于是被迫代替她去往敵國和親姨伤。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,507評論 2 359

推薦閱讀更多精彩內(nèi)容