[Unity] 基礎尋路演算法 - 程式碼實踐
本文始發於:https://www.cnblogs.com/wildmelon/p/16159189.html
一、前言
本文為常見的以圖作為資料結構的尋路演算法筆記,再次推薦該文章:
https://www.redblobgames.com/pathfinding/a-star/introduction.html
既有可互動的視覺化介面,又有由淺到深的尋路演算法講解。
二、廣度優先搜尋(BFS)
廣度優先搜尋的迴圈,是所有尋路演算法(基於圖節點)的關鍵。實際上會從起點開始,像在 Minecraft 遊戲裡倒一桶水般蔓延開來,遍歷到圖中的每個節點:
// 待搜尋隊列 Queue<TileNode> frontier = new Queue<TileNode>(); // 已搜尋節點 HashSet<TileNode> reached = new HashSet<TileNode>(); // 從起點開始 TileNode start = TotalTileModels[character.cellPosition]; frontier.Enqueue(start); reached.Add(start); // 遍歷搜尋上下左右的鄰節點 while (frontier.Count != 0) { TileNode current = frontier.Dequeue(); foreach (TileNode neighbor in current.neighbors) { if (!reached.Contains(neighbor)) { frontier.Enqueue(neighbor); reached.Add(neighbor); } } }
如果需要找到路徑,則用字典記錄每個節點的“父節點”,找到目的地之後,從尾節點向上返回到起點:
private void BFS() { Queue<TileNode> frontier = new Queue<TileNode>(); // 若 cameFrom[B]=A,說明B的上一節點為A,B是在A的鄰居中找到的 Dictionary<TileNode, TileNode> cameFrom = new Dictionary<TileNode, TileNode>(); TileNode start = TotalTileModels[character.cellPosition]; TileNode target = TotalTileModels[destination.cellPosition]; frontier.Enqueue(start); cameFrom.Add(start, null); while (frontier.Count != 0) { TileNode current = frontier.Dequeue(); foreach (TileNode neighbor in current.neighbors) { if (!cameFrom.ContainsKey(neighbor)) { frontier.Enqueue(neighbor); cameFrom.Add(neighbor, current); // 找到目標,提前退出 if (neighbor == target) break; } } } TileNode link = target; while (cameFrom[link] != null) { path.Add(link); link = cameFrom[link]; } path.reverse(); }
如果 path 為空,即字典 cameFrom 不存在 target。則說明沒有從終點返回起點的路徑。
三、Dijkstra 演算法
廣度優先搜尋對於所有的節點採取一視同仁的態度,但許多遊戲都存在地形的概念,如沙漠、森林等地點可能會消耗更多的步行時間。
此時我們引入成本記錄跟蹤,來得到 Dijkstra 演算法,為了在待搜尋節點中先計算當前成本最低的節點,需使用優先順序佇列:
private void Dijkstra() { // 優先順序佇列 PriorityQueue frontier = new PriorityQueue(); Dictionary<TileNode, TileNode> cameFrom = new Dictionary<TileNode, TileNode>(); // 當前節點已消耗的成本 Dictionary<TileNode, int> costSoFar = new Dictionary<TileNode, int>(); TileNode start = TotalTileModels[character.cellPosition]; TileNode target = TotalTileModels[destination.cellPosition]; // 從起點開始搜尋 frontier.Push(start, 0); costSoFar.Add(start, 0); cameFrom.Add(start, null); while (frontier.GetCount() != 0) { TileNode current = (TileNode)frontier.Out(); // 到達目標,提前退出 if (current == target) break; foreach (TileNode neighbor in current.neighbors) { int newCost = costSoFar[current] + neighbor.cost; // 未計算過該節點,或有新成本更低的路徑時,才進行計算 if (!costSoFar.ContainsKey(neighbor) || newCost < costSoFar[neighbor]) { frontier.Push(neighbor, newCost); costSoFar[neighbor] = newCost; cameFrom.Add(neighbor, current); mClickableTilemap.mTilemap.SetColor(neighbor.position, Color.blue); } } } while (cameFrom[target] != null) { mClickableTilemap.mTilemap.SetColor(target.position, Color.red); target = cameFrom[target]; } }
Dijkstra 演算法,會跳過中間消耗更高的水路:
同時可以發現,如果每個地塊的移動成本都是一致的(比如都是平原),那麼 Dijkstra 演算法和廣度優先搜尋其實是一樣的。
四、啟發式函式(Heuristic function)
無論是廣度優先搜尋還是 Dijkstra 演算法,都是以一種往外輻射的方式進行擴散的。但路徑尋找通常是有明確的方向性的(從起點指向終點方向),Dijkstra 演算法會往相反的方向擴散,對效能造成較大影響。
Dijkstra 演算法,只記錄了從起點到當前點的已消耗成本
。我們將定義一個啟發式函式,對距離目標還有多遠進行評估,來得到(預估成本
)。
某個節點的總成本
=已消耗成本
+預估成本
,在待搜尋隊列中,優先選取總成本最低的節點進行計算。
預估成本可以由距離、時間或者其他遊戲特定因素來決定的。
以距離為例,常用的啟發式函式有兩種:
- 曼哈頓距離:h(node) = |node.x-target.x| + |node.y-target.y|
- 歐幾里得距離:h(x) = sqrt(pow(node.x-target.x, 2) + pow(node.y-target.y, 2))
理想情況下,啟發式結果與真實結果越相近越好,上面兩個距離相關的啟發式函式,在節點離終點越近時,預估成本
都越小。
舉例來說,同樣是一片平原,已消耗成本
為6時,有多種方案可選,既可以往終點走6步,也可以遠離終點。而在加入了啟發式函式進行預估成本
評估後,顯然離終點更近的節點的預估成本
和總成本
會更低,並優先選取計算。
“曼哈頓距離”的預估成本通常會高於真實開銷,因為可能存在對角線斜走的可能,有一定概率無法發現最佳
路徑。
“歐幾里得距離(兩點之間的直線距離)”的預估成本通常低於真實開銷,則可能會遍歷更多的節點影響效能。
五、貪婪最佳優先演算法
在貪婪最佳優先演算法中,會忽略已消耗成本,只考慮啟發函式計算的預估成本。
即:某個節點的總成本
=預估成本
。
以“曼哈頓距離”為啟發式函式為例,貪婪最佳優先演算法會優先考慮離目標更近的點:
private void Greedy()
{
PriorityQueue frontier = new PriorityQueue();
Dictionary<TileNode, TileNode> cameFrom = new Dictionary<TileNode, TileNode>();
TileNode start = TotalTileModels[character.cellPosition];
TileNode target = TotalTileModels[destination.cellPosition];
frontier.Push(start, 0);
cameFrom.Add(start, null);
while (frontier.GetCount() != 0)
{
TileNode current = (TileNode)frontier.Out();
// 到達目標,提前退出
if (current == target) break;
foreach (TileNode neighbor in current.neighbors)
{
if (neighbor.nodeType == NodeType.Road && !cameFrom.ContainsKey(neighbor))
{
int cost = heuristic(neighbor, target);
frontier.Push(neighbor, cost);
cameFrom.Add(neighbor, current);
mClickableTilemap.mTilemap.SetColor(neighbor.position, Color.blue);
}
}
}
while (cameFrom[target] != null)
{
mClickableTilemap.mTilemap.SetColor(target.position, Color.red);
target = cameFrom[target];
}
}
在平坦地形上,貪婪演算法效率是很高的,因為他會直奔目標而去(優先考慮預估距離更近的點)。但當存在障礙物時,路徑質量可能不太理想,因為會一直先取更近的點,所以可能會得到不太準確的路徑:
如圖,起點左邊、上邊、下方的方塊由於距離更遠的原因,會排在優先順序佇列的尾部,起點右上方的方塊會優先計算並組成路徑。
六、A* 演算法
上文可知:
對於 Dijkstra 演算法,總成本
=已消耗成本
,可以很好地找到最短路徑,但在方向不確定的情況下會消耗更多的效能。
對於貪婪最佳優先演算法,總成本
=預估成本
,效率更高,但可能找不到最短路徑。
而 A* 演算法,綜合了考慮當下和未來(由啟發式函式決定):總成本
=已消耗成本
+預估成本
。
// 微調 Dijkstra 函式即可
...
int priority = newCost + heuristic(neighbor, target);
frontier.Push(neighbor, priority);
...
由上程式碼可知,Dijkstra 演算法可當作 heuristic 函式返回 0 的A*演算法。
七、總結
三種演算法是在“已消耗成本”與“預估成本”之間進行評估,以在效率和路徑質量之間做取捨。
本質上仍舊是開頭的“廣度優先搜尋”迴圈的一種優化。
視覺化對比三種演算法的不同,可參考 redblobgames 的這篇文章末尾:https://www.redblobgames.com/pathfinding/a-star/introduction.html