演算法修養--A*尋路演算法

暢知發表於2023-10-13

A*尋路演算法

廣度優先演算法

廣度優先演算法搜尋以廣度做未優先順序進行搜尋。

從起點開始,首先遍歷起點周圍鄰近的點,然後再遍歷已經遍歷過的點鄰近的點,逐步的向外擴散,直到找到終點。

這種演算法就像洪水(Flood fill)一樣向外擴張。直至洪水將整張地圖都漫延。在訪問節點時候,每個點需要記錄到達該點的前一個點的位置(父節點),訪問到終點時候,便可以從終點沿著父節點一路走回到起點,從而找出路徑。(注意:這也是A*演算法的一部分)

這種洪水蔓延式尋找路徑的方式較為野蠻粗暴,僅僅依據廣度來找路徑難以找到最短的路徑

image

Dijkstra演算法

在實際尋路場景中,要考慮“移動成本”。不同的路徑有不同的成本,例如,穿過平原或沙漠可能需要 1 個移動點,但穿過森林或丘陵可能需要 5 個移動點。玩家在水中行走的成本是在草地上行走的 10 倍。為此我們需要Dijkstra演算法。

在Dijkstra演算法中,需要計算每一個節點距離起點移動的總移動代價,同時,還需要一個優先佇列結構,對於所有待遍歷的節點,放入優先佇列中,優先佇列會按照代價進行排序。(這也是在下面要介紹的A*演算法實現案例中待最佳化的點!)

在演算法執行過程中,每次都從優先佇列中選出移動成本最小的作為下一個要遍歷檢查的節點,直到訪問到達終點為止。

image

兩種演算法的對比:

考慮這樣一種場景,在一些情況下,圖形中相鄰節點之間的移動代價並不相等。例如,遊戲中的一幅圖,既有平地也有山脈,那麼遊戲中的角色在平地和山脈中移動的速度通常是不相等的。

在Dijkstra演算法中,需要計算每一個節點距離起點的總移動代價。同時,還需要一個優先佇列結構。對於所有待遍歷的節點,放入優先佇列中會按照代價進行排序。

在演算法執行的過程中,每次都從優先佇列中選出代價最小的作為下一個遍歷的節點。直到到達終點為止。

下面對比了不考慮節點移動代價差異的廣度優先搜尋與考慮移動代價的Dijkstra演算法的運算結果:

廣度優先演算法: Dijkstra演算法:

image

當然,如果不考慮移動代價的因素,在同一個網格圖中Dijkstra演算法將和廣度優先演算法一樣。

啟發式搜尋?

透過廣度優先搜尋和 Dijkstra 演算法,邊界向各個方向擴充套件。如果試圖找到通往所有位置或許多位置的路徑,這是一個合理的選擇。

如果僅僅是找到到達一個位置的路徑,我們應當控制洪水的流向,時期朝向目標位置流動。而控制方向的條件判斷便可以透過啟發式搜尋來完成。

而所謂啟發式搜尋函式便是用來計算當前點到達目標點的距離(步數),預測到達目標點的距離。

啟發函式來計算到目標點距離方式有多種選擇:

  • 曼哈頓距離

    如果圖形中只允許朝上下左右四個方向移動,則啟發函式可以使用曼哈頓距離,它的計算方法如下圖所示:

    img

    曼哈頓距離=abs(node.x-target.x)+abs(node.y-target.y)

  • 對角移動

    如果圖形中允許斜著朝鄰近的節點移動,則啟發函式可以使用對角距離。

    img

  • 歐幾里得距離

    如果圖形中允許朝任意方向移動,則可以使用歐幾里得距離。

    歐幾里得距離2=(node.x-target.x)2+(node.y-target.y)2

採用啟發函式控制廣度優先搜尋的方式為(貪婪)最佳優先搜尋。

最佳優先搜尋(Best First)

最佳優先搜尋的原理也簡單,與Dijkstra演算法類似(廣度優先+移動成本),也使用一個優先佇列儲存啟發函式值(該點與目標點的距離),每次選取與目標點最近的點(鄰居)來訪問,直至訪問到終點。

可以理解演算法是廣度優先演算法+選擇最小目標點距離引數的結果。

image

同樣,由於沒有考慮移動成本,在移動成本不同的地圖中也不能考慮到森林、沼澤等移動速度不同的場景造成的移動成本的增加。不能保證找到的路徑是最短的路徑。

那就結合在一起唄!

A*演算法=廣度優先演算法+考慮“移動成本”+考慮“與目標點的距離”;

既可以保證聰明地選擇好走的路線避開難走的路線或者障礙物,也可以集中管控搜尋方向向著目標點中的位置搜尋。

下面是對三個演算法的詳細對比:

A*演算法集合了前兩個演算法的優勢,可以保證在兩點之間找到最佳的路線。

image

A*尋路演算法

基本原理:

A*演算法使用如下所示的函式來計算每個節點的優先順序:

\[f(n)=g(n)+h(n) \]

f(n)是節點n的綜合優先順序。

g(n)表示節點n距離起點的代價(距離或步數),即:從起點出發到達當前點所走的步數;

h(n)則表示節點n距離終點(目標點)的預估代價,即:從當前點達到終點需要走的步數,這是A*演算法的啟發函式,相當於提前預測要走到目標點還需要的步數。由於是預測,按照最短的路徑(步數)來表示,所以實際上到達終點的路程要等於或大於預測的數值。

尋路演算法的啟發函式控制:(理解兩個引數對搜尋的影響)

  • 在極端情況下,當啟發函式h(n)始終為0,則將由g(n)決定節點的優先順序,此時演算法就退化成了Dijkstra演算法。
  • 如果h(n)始終小於等於節點n到終點的代價,則A*演算法保證一定能夠找到最短路徑。但是當h(n)的值越小,演算法將遍歷越多的節點,也就導致演算法越慢。
  • 如果h(n)完全等於節點n到終點的代價,則A*演算法將找到最佳路徑,並且速度很快。可惜的是,並非所有場景下都能做到這一點。因為在沒有達到終點之前,我們很難確切算出距離終點還有多遠。
  • 如果h(n)的值比節點n到終點的代價要大,則A*演算法不能保證找到最短路徑,不過此時會很快。
  • 在另外一個極端情況下,如果h()n相較於g(n)大很多,則此時只有h(n)產生效果,這也就變成了最佳優先搜尋。

在尋路過程中,每次依據周邊節點的f(n)數值來選擇,每次選擇最小的f(n);

image

有障礙物的情況下的尋路(黑色方塊為障礙物)

image

程式碼實現:

//節點類
public class NodeBase{
    public NodeBase Connection{get;private set;}//儲存自己的相連線路徑中的節點
    public float G{get;private set;}
    public float H{get;private set;}
    public float F=>G+H;
    
    public void SetConnetion(NodeBase nodeBase)=>Connection=nodeBase;
    public void SetG(float g)=>G=g;
    public void SetH(float h)=>H=h;
}
//核心演算法
public static class Pathfinding {
    /// <summary>
    /// A*演算法核心
    /// </summary>
    /// <param name="startNode">開始點</param>
    /// <param name="targetNode">目標點</param>
    /// <returns>最短路徑</returns>
    /// <exception cref="Exception"></exception>
    public static List<NodeBase> FindPath(NodeBase startNode, NodeBase targetNode) {
        var toSearch = new List<NodeBase>() { startNode };//要處理搜尋的節點(未訪問過)
        var processed = new List<NodeBase>();//儲存訪問過的節點

        while (toSearch.Any()) {//判斷是否有要搜尋的元素節點
            //fixit 待最佳化的點 可以使用一個堆排序思想 
            var current = toSearch[0];
            foreach (var t in toSearch) 
                if (t.F < current.F || t.F == current.F && t.H < current.H) current = t;

            processed.Add(current);
            toSearch.Remove(current);//搜尋過後就移除

            current.SetColor(ClosedColor);

            //終點檢查
            if (current == targetNode) {
                var currentPathTile = targetNode;
                var path = new List<NodeBase>();//儲存路徑
                var count = 100;
                //從終點到出發點的節點路徑尋找
                while (currentPathTile != startNode) {
                    path.Add(currentPathTile);
                    currentPathTile = currentPathTile.Connection;
                    count--;//這裡根據地圖的大小設定一個路徑長度極限(可以忽略)
                    if (count < 0) throw new Exception();
                    Debug.Log("sdfsdf");
                }
                foreach (var tile in path) tile.SetColor(PathColor);
                startNode.SetColor(PathColor);
                Debug.Log(path.Count);
                return path;//返回路徑
            }
            //關心一下自己的鄰居--可以到達的鄰居,並且還未訪問過的(非障礙物)
            foreach (var neighbor in current.Neighbors.Where(t => t.Walkable && !processed.Contains(t))) {
                var inSearch = toSearch.Contains(neighbor);//獲取對鄰居的訪問狀況
                var costToNeighbor = current.G + current.GetDistance(neighbor);
                //更新一下鄰居的G值
                //對於已經訪問過的鄰居 如果從另一個方向過來想再次訪問,那麼G值應當比之前訪問的要小(只能是最近路程訪問)
                if (!inSearch || costToNeighbor < neighbor.G) {
                    neighbor.SetG(costToNeighbor);
                    neighbor.SetConnection(current);//記錄與之連線的節點(算作路徑中)
                    //如果是第一次訪問 還需要進行H值的計算
                    if (!inSearch) {
                        neighbor.SetH(neighbor.GetDistance(targetNode));//計算啟發函式值
                        toSearch.Add(neighbor);//將鄰居新增到要訪問的列表中
                    }
                }
            }
        }
        return null;
    }
}

參考文章:https://www.redblobgames.com/pathfinding/a-star/introduction.html

參考影片:https://www.youtube.com/watch?v=i0x5fj4PqP4

參考專案:https://github.com/zhm-real/PathPlanning

相關文章