GochenRyan的博客

往者不可谏,来者犹可追。

0%

游戏里的寻路算法

几个常用寻路算法的分析与实现。

图形

采用相邻列,每个节点都有一个相邻节点集合。

无权图

1
2
3
4
5
struct GraphNode
{
// Each Node has pointers to adjacent nodes
std::vector<GraphNode*> mAdjacent;
};
1
2
3
4
5
struct Graph
{
// A graph contains nodes
std::vector<GraphNode*> mNodes;
};

加权图

1
2
3
4
5
6
struct WeightedEdge
{
struct WeightedGraphNode* mFrom;
struct WeightedGraphNode* mTo;
float mWeight;
};
1
2
3
4
5
struct WeightedGraphNode
{
// Stores outgoing sdges
std::vector<WeightedEdge*> mEdges;
};
1
2
3
4
5
struct Graph
{
// A graph contains nodes
std::vector<WeightedGraphNode*> mNodes;
};

广度优先搜索(BFS)

首先检查起始节点一步范围内的所有节点,如果没有目标节点,再检查起始节点两步范围内的所有节点。以此类推,直至找到目标节点,或者再无有效移动为止。

在搜索期间,每一个节点都需要知道前一个被访问节点。那一个节点被称为“父节点”,该节点能够在广度优先搜索完成后,帮助重建路径。虽然可以将这些数据添加到GraphNode结构体中,但最好还是将不会变化(图形本身)的数据与其“父节点”分开。这是因为基于所选的起始和目标节点,父节点将会发生变化。分离这些数据段还意味着,如果想跨多线程同时计算多条路径,搜索就不会相互干扰。

1
using Node2ParentMap = std::unordered_map<const GraphNode*, const GraphNode*>;

使用队列来进行搜索。首先,从起始节点入队,并输入一个循环。在每次迭代中,都需要出列一个节点,并入队其相邻节点。可以通过检查“父映射”,来避免多次将同一节点添加到队列中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
bool BFS(const Graph& graph, const GraphNode* start, 
const Graph* goal, Node2ParentMap& outMap)
{
bool pathFound = false;
std::queue<cosnt GraphNode*> q;
q.emplace(start);

while (!q.empty())
{
const GraphNode* current = q.front();
q.pop();
if (current == goal)
{
pathfound = true;
break;
}

for (const GraphNode* node: current->mAdjacent)
{
const GraphNode* parent = outMap[node];
if (parent == nullptr && node != start)
{
outMap[node] = current;
q.emplace(node);
}
}
}
retrun pathFound;
}

如果BFS成功,就可以使用outMap中的父指针来重建路径。跟随父指针这条链条,将会产生一条目标节点到起始节点的链条,所以应采用反向搜索

针对加权图,BFS无法保证找到最短路径。这是因为BFS根本不考虑边的权重,对于BFS,每次边的遍历都是等同的。

heuristic函数

在寻路算法中,heuristic函数是从给定节点到目标节点的估计成本。

如果heuristic函数总是小于或等于从节点x到目标节点的实际成本,那么该函数是可接受的;如果heuristic函数偶尔会高过实际成本,那么该函数便是不可接受的。这种情况下,搜索应该放弃使用该函数。

曼哈顿距离

$h(x) = |start.x - end.x| + |start.y- end.y|$

曼哈顿距离假定对角线移动是无效的,如果对角线距离有效,曼哈顿距离则会经常高估成本,使得heuristic函数不可接受

欧几里德距离

$h(x) = \sqrt{(start.x - end.x)^2 + (start.y- end.y)^2}$

贪婪最佳优先搜索(GBFS)

和BFS不同,GBFS并不是以FIFO方式使用队列来考虑节点的,而是根据启发(Heuristic)函数来决定接下来考虑哪个节点。

搜索过程中那个,GBFS并不是使用队列,而是使用两组节点。开集包含正在考虑的节点。一旦节点被选用于评估,就会被移动到闭集中。

对于开集,需要进行两个操作:

  • 删除成本最低的节点
  • 测试成员身份

对与闭集,只需要测试成员身份,所以对闭集不需要采用实际集合。

在搜索过程中,每个节点都需要额外数据。有多个临时数据,采用结构体:

1
2
3
4
5
6
7
struct GBFSScratch
{
const WeightedEdge* mParentEdge = nullptr;
float mHeuristic = 0.0f;
bool mInOpenSet = false;
bool mInCloseSet = false;
}

定义一个映射:

1
using GBFSMap = std::unordered_map<const WeightedGraphNode*, GBFSScratch>;

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
bool GBFS(const WeightedGraph& g, const WeightedGraphNode* start, 
const WeightedGraphNode* goal, GBFSMap& outMap)
{
std::vector<const WeightedGraphNode*> openSet;
const WeightedGraphNode* current = start; // 当前节点为start
outMap[current].mInCloseSet = True;

while (current != goal)
{
for (const WeightedEdge* edge: current.mEdges)
{
GBFSScratch& data = outMap[nodeTo];
if (!data.mInCloseSet)
{
data.mParentEdge = current;
if (!data.mInOpenSet)
{
data.mHeuristic = ComputeHeuristic(edge->mTo, goal);
data.mInOpenSet = True;
openSet.emplace_back(edge->mTo);
}
}
}

if (openSet.empty())
{
break;
}

// 在开集中找成本最小的节点
auto iter = std::min_element(openSet.begin(), openSet.end(),
[&outMap](const WeightedGraphNode* a, const WeightedGraphNode* b)
{
return outMap[a].mHeuristic < outMap[a].mHeuristic;
});

current = iter;
outMap[current].mInOpenSet = false;
outMap[current].mInCloseSet = true;
openSet.erase(current)
// openSet不能清空,可能出现当前成本最小的节点周围都是障碍物的情况
}
return (current == goal) ? true: false;
}

A*搜索

A*对GBFS做了一些修改,引入了路径成本组件,该组件是从起点到给定节点的实际成本。符号$g(x)$表示节点的路径成本。A*会选择具有最低$f(x)$值的节点:

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
bool AStar(const WeightedGraph& g, const WeightedGraphNode* start, 
const WeightedGraphNode* goal, AStarMap& outMap)
{
std::vector<const WeightedGraphNode*> openSet;
const WeightedGraphNode* current = start; // 当前节点为start
outMap[current].mInCloseSet = True;

while (current != goal)
{
for (const WeightedEdge* edge: current.mEdges)
{
WeightedGraphNode& nodeTo = edge->mTo;
AStarScratch& data = outMap[nodeTo];
if (!data.mInCloseSet)
{
if (!data.mInOpenSet)
{
data.mParentEdge = current;
data.mHeuristic = ComputeHeuristic(nodeTo, goal);
data.mActualFromStart = outMap[current].mActualFromStart + nodeTo.mWeight;
data.mInOpenSet = True;
openSet.emplace_back(nodeTo);
}
else {
float newG = outMap[current].mActualFromStart + nodeTo.mWeight;
if (newG < data.mActualFromStart)
{
data.mParentEdge = current;
data.mActualFromStart = newG;
}
}
}
}

if (openSet.empty())
{
break;
}

// 在开集中找成本最小的节点
auto iter = std::min_element(openSet.begin(), openSet.end(),
[&outMap](const WeightedGraphNode* a, const WeightedGraphNode* b)
{
// f = g + h
float fOfA = a.mActualFromStart + a.mHeuristic;
float fOfB = b.mActualFromStart + b.mHeuristic;
return fOfA < fOfB;
});

current = *iter;
openSet.erase(iter)
outMap[current].mInOpenSet = false;
outMap[current].mInCloseSet = true;
// openSet不能清空,可能出现当前成本最小的节点周围都是障碍物的情况
}
return (current == goal) ? true: false;
}