1. 程式人生 > 其它 >A星尋路演算法

A星尋路演算法

一、A星尋路演算法介紹

當你在製作一款遊戲的時候是否想過讓你的角色避開道路上的障礙物從而抵達終點呢?

如果有的話,那麼這篇文章你要認真看下去,至少可以幫助你初步建立一個利用A星演算法的思路實現它!

本篇文章將從演算法最基本的思路講起,讓我們開始吧!

二、一張棋盤格

讓我們來看這張圖,你創造的主角小紅,想要到達小黃所在的位置,這條線路我們應該怎麼找。

顯然圖片中黑色的部分不像是小紅能直接穿過的地方,我們需要繞一繞,也許你有很多條路可以走,但我現在告訴你,我們趕時間,我們需要找出最短的路!

如何解決這個問題呢,A星演算法來了。

三、基本思路

我們將每個位置預設為一個正方格,他的目的是便於我們之後的計算

不要質疑為什麼你的主角變了顏色,這並不影響我們的講解。

我們創造了一個簡單的搜尋區域,八個方向,並且我們用小本本記下了兩個列表:open列表(記錄下所有被考慮來尋找最短路徑的方塊) 和 close列表(記錄下不會再被考慮的方塊)

首先我們將起點新增入close列表中(我們將起點設為“A”,深綠色方框),再將A附近所有可行方塊新增入open列表中(綠色描邊方框)

路徑增量

我們給每一個方塊一個G+H和值

G為從起點A到當前點的移動量(代表本文中的方塊數量),所以從A開始到相鄰點的G值為1,這個值會隨著角色的移動(或者說距離開始點)越來越遠而增大。

H為從當前所在點到終點(我們將它設為B!)的移動估算量,這個常被成為探視,因為我們不確定它的移動量的準確數值,所以這個H僅僅只是一個估算值。

(值得一提的是,這個H的估算值我們有多種辦法算取,你可以使用“曼哈頓距離演算法”,或是尤拉公式等,它只是計算出點B剩下的水平垂直方塊的數量,忽略掉中途的任何障礙物)

在A星演算法中移動量這個值是由你來決定的,你可以僅僅允許主角進行上下左右四個方向的移動,或者你可以將移動量針對地形調整到大一點。

四、演算法原理

既然你已經知道G和F,我們來認識一下這個演算法最核心的值——F值,它有一個公式:F=G+H,它的意義是方塊的移動總代價(或稱為和值)

在演算法中,角色將重複下列幾個步驟來尋找最短路徑:

1,將方塊新增到open列表中,且該方塊擁有最小的F和值,我們暫且將它稱為S。

2、將其從open列表中移除,然後新增入close列表中。

3、對於S相鄰的每一個方塊,都有:

若該方塊在close列表中,我們不管它。

若改方塊不在open列表中,計算它的F和值並將其新增入open列表中。

若該方塊已經在open列表中,當我們沿著當前路徑到達它時,計算它的F和值是否更小,如果是,前進並更新它的和值和它的前繼。

為了幫助你理解它的原理,我們來舉個例子吧:

在接下來的每一步中,綠色方框代表我們可以選擇的方塊,而已選擇的方塊我們會用紅色邊框將它點亮。

第一步,我們要確定起點附近的每一個方塊,計算他們的F值並將其新增入open列表中,在圖片中,方框左下方的數字代表G值,為了確保你學會了怎麼使用“擷取距離演算法(忽略障礙物由A到B的位移量)”H值,我們不打算將其標入方框中,最終,將G與你計算出的H值相加便得到左上角的F和值。

第二步,選擇其中F值最小的方塊並將其新增入close列表中,再次檢索它相鄰的可行方塊,我們發現有兩個一模一樣的方塊可選,而根據剛才講到的第三條定理,我們發現上下兩個方塊都已經在open列表中,且我們通過計算髮現,第一步時它的G值為1,但當我們經由當前已在的“4,1”方格在到達那裡時,它的G值將變為2(因為我們繞了一下,所以移動了兩步),顯然2比1大,因此從“4,1”再走到“5,1”並不是最優路徑,我相信你是一個有遠見的人,所以你會從第一步就選擇“5,1”方塊。

第三步、當你選擇走最優路徑時,你會發現一個問題,“5,1”方塊有兩個,也就是說有兩條一模一樣的路可以走,但真的是這樣嗎,我們保留這個疑問,隨便選擇一個,比如我選擇走上邊的“5,1”方塊。

再次檢索周圍方塊,並忽略掉障礙物。我們得到如下圖的資訊。

我們發現有好幾個F值都為6的,沒關係,我們都考慮上並計算他附近的F值,在這裡我們也順便將剛才未選擇的下方“5,1”方塊周圍的F值計算一下

可以看到其實左邊的方塊其實是不用考慮的,我們人眼一看就知道接著剛才的路繼續尋找就好了,但是程式並不知道,他只會老老實實執行你給他規定的步驟,這算是必踩的坑。

但是有一種比較簡便的方法是,規定一直沿著最近被新增入open列表的方塊。

好了現在你已經訓練有素了,經過幾次重複你得到了下圖這樣的路徑

你成功到達了終點,他已經在open列表中了,當你邁出最後一步時,程式會將它從open中移除並新增入close列表中。

最後,演算法,演算法需要做的是就是沿著路徑返回並計算出最優路徑。

讓我們將最終路徑用藍色方框強調出來。

程式碼部分

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title></title>
        <style type="text/css">
            #ul1{
                margin: 30px auto;
                border: 1px solid black;
                border-bottom: none;
                border-right:none ;
                padding: 0;
                height: auto;
                overflow: hidden;
            }
            #ul1 li{
                list-style: none;
                border: 1px solid black;
                border-top:none ;
                border-left:none ;
                float: left;
            }
            #ul1 li.style1{
                background-color: red;
            }
            #ul1 li.style2{
                background-color: black;
            }
            #ul1 li.style3{
                background-color: orange;
            }
            #btn{
                position: absolute;
                left: 50%;
                margin-left: -50px;
            }
            #btn:hover{
                background-color: #E21918;
                color: white;
                border-radius: 4px;
            }
        </style>
    </head>
    <body>
        <ul id="ul1">
            
        </ul>
        <input id="btn" type="button" value="開始尋路"/>
        <script type="text/javascript">
            var oUl = document.getElementById("ul1");
            var aLi = document.getElementsByTagName("li");
            var beginLi = document.getElementsByClassName("style1");
            var endLi = document.getElementsByClassName("style3");
            var oBtn = document.getElementById("btn")
            //演算法實現
            /**
             * open佇列: 收集可能會需要走的路線   要走的路線放在open佇列中  
             * close佇列: 排除掉不能走的路線        不走的路線放在close佇列中
             * 
             */
            //可能要走的路線
            var openArr = []
            //已經關閉的路線
            var closeArr = []
            var map = [
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,2,2,2,2,0,0,0,0,3,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,
                2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
                0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
            ]
            //最終線路陣列
            var resultParent = [];
            init();
            //初始化函式
            function init(){
                createMap()
                //點選按鈕的時候 需要去收集可能走的路線
                oBtn.onclick = function(){
                    openFn();
                }
            }
            //建立地圖
            function createMap(){
                var liSize = 20;
                for(var i=0;i<map.length;i++){
                    var oLi = document.createElement("li");
                    oLi.style.width = liSize +"px";
                    oLi.style.height = liSize + "px";
                    oUl.appendChild(oLi);
                    if(map[i]==1){
                        oLi.className = "style1";
                        //當元素剛開始建立的時候,open佇列中的元素只有 起始節點    也就是說將紅色點都放到open佇列中 並且 剛開始的時候 起始點只有一個
                        openArr.push(oLi);
                    }else if(map[i]==2){
                        //當元素剛剛開始建立的時候  close佇列中的元素 就是 值為2的元素   也就是說 把黑色的點都放到close佇列中  這些作為障礙物 是不會走的
                        oLi.className = "style2";
                        closeArr.push(oLi);
                    }else if(map[i]==3){
                        oLi.className = "style3"
                    }
                }
                //ul的寬頻等於 ul的左邊 1 + 20個節點的寬頻 20*(liSize+1)  其中 liSize+1 是因為 節點有1個畫素的右邊框
                oUl.style.width = 20*(liSize+1)+1+"px"
            }
            //估價函式
            function fn(nowLi){
                return g(nowLi)+h(nowLi)
            }
            //初始點到當前節點的實際代價
            function g(nowLi){
                //勾股定理
                var a = nowLi.offsetLeft-beginLi[0].offsetLeft;
                var b = nowLi.offsetTop - beginLi[0].offsetTop;
                return Math.sqrt(a*a+b*b)
            }
            //當前節點到目標點的實際代價
            function h(nowLi){
                //勾股定理
                var a = nowLi.offsetLeft-endLi[0].offsetLeft;
                var b = nowLi.offsetTop - endLi[0].offsetTop;
                return Math.sqrt(a*a+b*b)
            }
            
            
            /**
             * 實現的功能: 1 把open佇列中的元素移到close佇列中,表示起始節點已經走過了,那麼接下來應該走哪一步呢?
             *                2 把起始位置周圍的 8 個點都找出來 並且 計算出 估價函式值最低的那個元素  那麼這個元素就是接下來要走的這步
             *                3 接下來走的這步確定了 那麼就又該把這個位置的點移動到 close佇列中,然後繼續找周圍的點 並且進行估價   以此類推
             */
            function openFn(){
                //nodeLi 表示 當前open佇列中的元素  也就是說 先去除第一個起始節點
                //shift 方法的作用: 把陣列中的第一個元素刪除,並且返回這個被刪除的元素
                var nodeLi = openArr.shift();
                //如果nodeLi 和 endLi 一樣了 那麼證明已經走到目標點了 ,這個時候需要停止呼叫
                if(nodeLi == endLi[0]){
                    showPath();
                    return;
                }
                //把open佇列中刪除的元素 新增到 close佇列中
                closeFn(nodeLi)
                //接下來 需要找到 nodeLi 周圍的節點
                findLi(nodeLi);
                
                //經過上面的步驟 已經能夠找到相鄰的元素了  接下來需要對這些元素的估值進行排序
                openArr.sort(function(li1,li2){
                    return li1.num - li2.num
                })
            
                //進行遞迴操作 找下一步需要走的節點 在這個過程中,也需要執行相同的步驟 那就是查詢相鄰的節點  但是查找出來的結果可能和上一次的重複,也就是說上一次動作已經把這個元素新增到open佇列中了
                //那麼就沒有必要再進行push操作了  所以還需要在過濾函式中加一段程式碼
                openFn();
            }
            
            function closeFn(nodeLi){
                //open佇列中刪除的元素 被 push到close佇列中
                closeArr.push(nodeLi);
            }
            
            /**
             * 封裝函式查詢某個節點周圍的節點
             */
            function findLi(nodeLi){
                //建立一個結果陣列 把查詢到的結果放到這個陣列中
                var result = [];
                //迴圈所有的li節點 進行查詢
                for(var i=0;i<aLi.length;i++){
                    //如果經過過濾 返回的是true 表示 這個節點不是障礙物 那麼需要新增到result結果陣列中
                    if(filter(aLi[i])){
                        result.push(aLi[i]);
                    }
                }
                //接下來需要在沒有障礙物的結果中去找 和 當前節點相鄰的節點
                //判斷條件是 他們的橫縱座標的差值需要小於 等於 網格大小
                for(var i=0;i<result.length;i++){
                    if(Math.abs(nodeLi.offsetLeft - result[i].offsetLeft)<=21 && Math.abs(nodeLi.offsetTop - result[i].offsetTop)<=20+1 ){
                        //這裡的result[i]就是當前目標點相鄰的節點  把這些節點傳入到估價函式就能得到他們的估值,並且要把這些估值掛載到他們自身的一個自定義屬性上
                        result[i].num = fn(result[i]);
                        //nodeLi 是當前的位置  result[i] 是當前位置相鄰的點  下一次要走的位置就在這幾個點中,所以給result[i]定義一個parent屬性
                        //來存上一次的路徑 ,最終把這些路徑聯絡起來就是完整的路徑
                        result[i].parent = nodeLi;
                        openArr.push(result[i]);
                    }
                }
            }
            /**
             * 封裝函式 實現過濾功能
             * 這個函式的功能就是 接收到一個li 判斷是否是障礙物 如果是 就返回false  如果不是就返回true  
             */
            function filter(nodeLi){
                //迴圈close佇列中的所有元素 與傳過來的節點進行比對 如果比對成功 返回false 
                for(var i=0;i<closeArr.length;i++){
                    if(nodeLi == closeArr[i]){
                        return false;
                    }
                }
                for(var i=0;i<openArr.length;i++){
                    if(nodeLi == openArr[i]){
                        return false;
                    }
                }
                //如果迴圈完都沒有匹配上 那麼證明當前傳過來的 li節點 並不是障礙物 
                return true;
            }
            /**
             * 打印出所走過的路徑
             */
            function showPath(){
                //closeArr中最後一個 就是 找到目標點的前一個位置  因為走過的位置都會被存放在closeArr中
                var lastLi = closeArr.pop();
                var iNow = 0;
                //呼叫findParent函式 來找上一個節點
                findParent(lastLi)
                
                var timer = setInterval(function(){
                    resultParent[iNow].style.background = "red";
                    iNow++;
                    if(iNow == resultParent.length){
                        clearInterval(timer);
                    }
                },500)
            }
            /**
             * 定義一個函式來找到上一次走過的節點
             */
            
            function findParent(li){
                resultParent.unshift(li);
                if(li.parent == beginLi[0]){
                    return;
                }
                findParent(li.parent);
            }
        </script>
    </body> 
</html>

(我們在該程式碼中使用的是勾股定理來計算,實際上與街區演算法原理)

這便是所有程式碼部分了,其中都包含有對每部分的註釋講解,讀者可自行閱讀理解。

執行結果

我在這裡插入一個比較優秀的A星演算法演示連結,方便各位理解演算法思路。

http://qiao.github.io/PathFinding.js/visual/

成品展示連結

連結:https://pan.baidu.com/s/1Y4OaovodEtBeXUCRdOKDIg
提取碼:dt68

小組成員:楊豪傑 劉益 謝君 楊千禧