[0 Basic Tutorial] Detailed Explanation of the Pathfinding Algorithm of Star A that can be understood at the elementary school mathematics level (with Go code)

I. Overview

A-Star (A-Star) pathfinding algorithm is often used in game programming, such as how to move from the starting point to the end point after the movement command is given to the character, or to control the NPC to go from one place to another and so on.

This article refers to this article of Myopic Rhino:
https://www.gamedev.net/reference/articles/article2003.asp
Chinese translation:
https://blog.csdn.net/weixin_44489823/article/details/89382502

In the original text, only the idea of ​​the algorithm is mentioned but there is no specific code implementation, so there are still some pits to be filled in the specific implementation. This article tries to use the most simple language to express. Although it is a bit wordy, everyone can understand it. In fact, the A-star algorithm itself does not contain too many mathematical theories, so I believe it is still easy for most people to understand. of.

First of all, the A star algorithm is a pathfinding mechanism based on a grid system. It divides the map into small grids, and determines the direction of the path by calculating the weight of each grid. Now imagine the familiar digital nine-square grid. The "5" key in the middle is surrounded by 8 numbers next to it. 8 numbers represent 8 directions, so which direction should you choose to move? This is what the A-star algorithm does.

insert image description here


2. Basic concepts

  • G、H、F

    The entire A-star algorithm operates around the three parameters of G, H, and F. Their definitions are very simple. For example, in the figure below, there is a starting grid, an ending grid, and a grid P:

    G represents the distance from P to the starting point;

    H represents the distance from P to the end point;

    F represents the sum of the two, namely G+H.

insert image description here


3. Detailed Algorithm

insert image description here

Let's take an 8*8 map as an example, F stands for the starting point (From), T stands for the end point (To), and the red square in the middle is the "wall", that is, the place that cannot be passed. Now use the A star algorithm step by step Let's see how it goes from F to T.


first round:

insert image description hereWe start from the starting point F (1, 3), and the "neighboring squares" adjacent to it are indicated in yellow. Remember the nine-square grid mentioned earlier? Yes, the entire A-star algorithm operates according to the Jiugongge method.

In the adjacent yellow squares, there are 3 numbers in each square. These 3 numbers are the G, H, and F values ​​mentioned above. The lower left corner represents the G value (distance from the starting point), and the lower right corner represents H value (distance from the end point), as for F, it is very simple, it is G+H. As for the specific operation of the value, we stipulate:

  • Translating 1 grid is recorded as 10;

  • Slanting 1 square (that is, moving diagonally) is recorded as 14.

The reason why we choose the number 14 is because we directly take the approximate value of the root number 2 to do integer calculations, avoiding the actual operation of the square root of the algorithm in the algorithm, and the efficiency will be greatly improved.

For example, take the grid (2, 4) as an example, its G is 14 (slope one grid to the left to reach the starting point), and its H is 50, which means moving from (2, 4) to the end point T (6, 3) 5 shifts are required.

Note: When calculating the H value, we use the so-called "Manhattan Distance" (Manhattan Distance), which means that only translation is considered, and no skew operation is performed. (In Manhattan, New York, when you walk from one block to another, you can only pan over it and not pass through buildings, hence the name)

In addition: the calculation of H ignores the existence of obstacles ("walls"), H is the estimated value of the remaining distance, not the actual value, the A-star algorithm is essentially the continuous approach to the end point and finally finds the path.

In addition to these 3 numbers, each yellow grid also has a black arrow, and its direction indicates who its "parent square" is. Obviously, because it is the first round now, these 8 yellow squares now Parent squares (or parent nodes) are all F.


second round:

insert image description here
Now it is the second round, the squares (2, 3) on the left of the starting point have turned blue, while the starting squares of the previous round have turned black. Let me explain the internal logic of this change. In the previous round, the yellow cell with the smallest F value (F=50) was (2, 3), so it was selected as the processing cell for this round, and so on in every subsequent round. First determine the neighbors (yellow squares in the example), and then calculate their GHF values. Finally, look at which of all the yellow grids has the smallest F value. Note that it is not the neighbor grid generated in this round, but check the F value in all the yellow grids, and locate the smallest one as the processing grid for the next round of screening.

The starting grid (1, 3) is now black because every processed grid will no longer be used in each subsequent round. So we need to maintain an array called closeList , and store all the processed grids in it for no longer use.

Correspondingly, all the yellow grids also need an array to maintain, we call it openList , only one grid is selected in each round as the processing grid for the next round, and the unselected grids will be put into the openList for temporary storage .

Please observe the current grid (2, 3), the three left of the 8 grids centered on it are red walls, which are ignored, and the left one is the black grid (1, 3), which has been put into the closeList, as mentioned earlier All grids placed in the closeList are no longer processed, so they are also ignored. What needs to be dealt with is the remaining four yellow grids: (1, 2), (2, 2), (1, 4) (2, 4), in fact, these 4 grids have been placed in the previous round openList . What needs to be done now is to select the one with the smallest F value as the processing grid for the next round. At this time, differences arise, because there are two grids of F=64 (2, 2) and (2, 4), so how should we choose? The answer is to ignore it, simply take the last one added as the standard, and observe the picture, you will find that the path forks will occur when the F values ​​are equal. The A star algorithm does not have such high intelligence to judge whether it is better to go up or down. It can only choose one and then keep approaching.

There is another point that needs to be discussed in particular, that is, when dealing with the grid that has been included in the openList , it is also necessary to pay attention to whether the distance (G value) between it and the starting point is greater than the G value generated by the current grid . This sentence sounds very awkward, we still use pictures to illustrate, such as this situation:

insert image description here
Looking at the above picture, the G value of the square in the upper right corner is 14, which means that you only need to move one square to reach the starting point (indicated by the red arrow in the figure). If it diverts through the current square (purple square), the G value will be 20, because it needs to move twice to reach the starting point (indicated by the green arrow in the figure), obviously 14 < 20, indicating that this grid is the best way to follow the original path Excellent, in this case we don't have to do anything, just ignore it. Let's look at a counterexample:

insert image description here

It is still the grid in the upper right corner, but the starting point is different. If you follow its old route, you need to move obliquely twice to reach the starting point, and the G value is 28. But if it goes through the current grid (purple grid) instead, it only needs to translate twice, and the G value is 20. Obviously 28 > 20, so we need to modify its parent grid pointer to make it point to the current grid, as shown in the figure:

insert image description here
I saw a lot of comments, and many people have doubts about this knowledge point, but this is not a problem of understanding, but that it is difficult to express this concept in words. Combining these three pictures, I think it is still easy to understand. Without further ado, let's go back to our case.


Third round:

insert image description here

In the third round, the current processing grid is (2, 4), which is the center of the Jiugong grid. The wall does not need to be processed, and the two black grids that have been placed in the closeList do not need to be processed. Its left (1, 4) is already The grid that is included in the openList has a G value of 10. And if you change to the path through the current grid, the G value will become 10+14 = 24, which is obviously unnecessary, so it is also ignored. Below it, 3 new grids that are not included in the openList have been opened (note that I deliberately changed the color to be slightly different), and it is very simple to deal with these newly opened grids. You only need to calculate their G, H, and F values ​​separately , and then point their parent grid pointers to the current grid (as shown in the figure).


Fourth round:

insert image description here

Except for the opening of two new grids this round, there is not much change.

Fifth round:

insert image description here

This round came to the grid (0, 3) with G=70. Its left side is the border, so there are no new grids to open, and the G value of the surrounding grids that have been opened does not need to be modified. It is a calm round.


Sixth round:

insert image description here

Note: In this round, the G value of the grid that already exists in the openList mentioned above is greater than that of the current grid. Please analyze this round carefully.

Observe the change of the grid (1, 1) between the current round and the last round, and you will find that its pointer has changed. Because if you start from (1, 1), you need to go through (2, 2) and then go to the starting point F according to the original path, which requires two oblique shifts (G value is 28). However, if via (1, 2), only two translations are needed (G value is 20), so its pointer points to the current grid. So far, that’s basically all about the possible situations that may happen during the path-finding process of the A-star algorithm. Next, I will only put a general picture, so there is no nonsense. Please analyze it yourself.

insert image description hereThe white text indicates the order in which the A star algorithm is opened. When the end point is also placed in the openList , it means that there is a path from the start point to the end point. But how do we determine the path? It's very simple, we can reverse the parent node of each node from the end point (the red arrow in the figure indicates the reverse push process). Because each node has only one definite parent node, nodes that have been used in the pathfinding process but failed to become paths will be skipped.

Imagine, assuming we use a wall to block all the paths, the algorithm will keep looking for the next possible path in the openList. When all the elements in the openList are exhausted, it means there is no way to go. Therefore, there are two termination conditions for the pathfinding algorithm:

1. Does openList include the end point? (path found)

2. Is openList empty? (no path)


4. Code implementation

I believe that if you can see this patiently, you can almost write the code by hand, but there are still a few small holes that need to be filled. The first is the calculation of the G value. Before discussing this issue, let’s take a look at the topic of the distance formula. There are three commonly used distance formulas:

  • Manhattan distance

    We mentioned the "Manhattan distance" before, which refers to a way that can only move up, down, left, and right, but not diagonally. One move is recorded as 1.

  • Chebyshev distance

    Chebyshev is very similar to the Manhattan distance. It conceptually allows the existence of slashes, but it will move 1 square straight line or slash is equivalent to a distance of 1.

  • Euclidean distance:

    It is the real distance between two points in the coordinate system. The disadvantage is that it needs to do arithmetic square root operation.

    Here are the differences between the three distance formulas:

insert image description here


Then the problem comes. When we calculate G, we need to record the slash as 14 and the straight line as 10. This is a pseudo "Euclidean distance" that avoids the system from doing arithmetic square roots. Because the translation distance between two points is 1, then its diagonal distance is the square root of 2 (the value is 1.414), and in order to avoid floating-point numbers, we multiply it by 10 on the basis of its approximate value of 1.4, then correspondingly, Translation also needs to be scaled up (1x10=10), which is why we chose 10 and 14 as distance constants. In addition, we also need to design a pseudo "Euclidean distance" algorithm, the following is the code:

func calculate_G(p1 Point, p2 Point) int {
    
    
    dx := math.Abs(float64(p1.x - p2.x))
    dy := math.Abs(float64(p1.y - p2.y))
    straight := int(math.Abs(dx - dy))
    bias := int(math.Min(float64(dx), float64(dy)))
    distance := straight*10 + bias*14
    return distance
}

The method is to first obtain the Manhattan distance between two points, then multiply the smaller value by 14, and multiply the difference between dx and dy by 10. The code is written in Golang, but because of its very simple logic, it is basically language-independent.

The second is the sorting problem. Do you still remember that at the end of each round, you need to find a square with the smallest F value from the openList as the processing square for the next round? Although the choice of sorting algorithm does not affect the final result, the efficiency can vary greatly. In the beginning, I used the bubbling method to save trouble, but later switched to the binary heap, and the efficiency was at least 5 times higher. The following is the binary heap sorting algorithm:

// 寻找list中的最小结点(二叉堆方法)
func findMin_Heap(tree []Node) Node {
    
    
    var n = len(tree) - 1
    // 建小根堆 (percolate_down)
    for i := (n - 1) / 2; i >= 0; i-- {
    
    
        percolate_down(tree, n, i)
    }
    return tree[0]
}
// 建堆(下滤)
func percolate_down(tree []Node, size int, parent_node int) {
    
    
    left_node := 2*parent_node + 1
    right_node := 2*parent_node + 2

    var max_node = parent_node
    if left_node <= size && tree[left_node].f < tree[parent_node].f {
    
    
        max_node = left_node
    }
    if right_node <= size && tree[right_node].f < tree[max_node].f {
    
    
        max_node = right_node
    }

    if max_node != parent_node {
    
    
        tree[parent_node], tree[max_node] = tree[max_node], tree[parent_node]
        percolate_down(tree, size, max_node)
    }
}

In fact, binary heap sorting requires two steps, first to build the heap, and then to sort. But in our scenario, we don’t need to actually sort, but only need to be able to take out the minimum value. When the binary heap is built, the minimum value can already be guaranteed to be at the top of the heap, even though the entire heap is still out of order Yes, but it can ensure that the first value in the linked list is the minimum value, which is very efficient, so the A-star algorithm combined with the binary heap is the first choice.

But if you don't quite understand the principle of binary heaps, you can't explain it clearly in a few words. Here is a blog post of mine for reference:

Binary heap and heap sorting detailed nanny level tutorial is a bit wordy but guaranteed to be understandable_Binary heap sorting_rockage's blog-CSDN blog

There is nothing else to say about the code. The important thing is to maintain the two arrays of openList and closeList . Each round of pathfinding may add, delete, or modify these two arrays, which is the core of the entire algorithm. The following is the overall operation process:

  1. Put the starting point into openList , let it be the first "current grid", pathfinding begins

  2. Put the current grid into the closeList to ensure that the next round will not be processed

  3. Taking the current grid as the center, calculate the G, H, and F of the 8 neighboring grids that wrap it, if:

    • Neighbors beyond map boundaries -> ignore

    • Neighbors are on obstacles (walls) -> ignore

    • Neighbor cell already exists in closeList -> ignore

    • Neighbor cells are new cells, not in openList :

      • Calculate its G, H, F values, and set its parent grid as the current grid.
    • Neighbors are not new squares, they already exist in openList :

      • Calculate its G value to the starting point, if it is less than the G value generated by the current cell -> ignore

      • If greater than the G value generated by the current cell:

        • Modify the parent grid of this neighbor grid to the current grid, and update the G and F values
  4. Process all eligible neighbor cells and store them in openList

  5. When all 8 directions are detected and calculated, select a grid with the smallest F value from the openList , and the current round ends

  6. Set the grid with the smallest F value selected in the previous round as the "current grid" of the new round

  7. Repeat steps 2-6

  8. Loop end judgment

    After each round of pathfinding, openList needs to be judged:

    1. The end point has been included in the openList , indicating that the path has been found. Starting from the end point, the path is reversed according to its parent node, and the program ends.

    2. The element in openList is 0, indicating that the path cannot be found, and the program ends.


package main

import (
	"fmt"
	"math"
	"time"
)

type Point struct {
    
    
	x int
	y int
}

type Node struct {
    
    
	coordinate Point
	parent     *Node
	f, g, h    int
}

// 地图大小
const cols = 8
const rows = 8

func main() {
    
    

	// 创建起点和终点 (F=From T=To)
	F := Point{
    
    1, 3}
	T := Point{
    
    6, 3}

	var obstacle []Point
	// 创建障碍
	obstacle = append(obstacle, Point{
    
    3, 1})
	obstacle = append(obstacle, Point{
    
    3, 2})
	obstacle = append(obstacle, Point{
    
    3, 3})
	obstacle = append(obstacle, Point{
    
    3, 4})

	// 创建地图
	var preMap [cols][rows]byte
	for y := 0; y <= rows-1; y++ {
    
    
		for x := 0; x <= cols-1; x++ {
    
    
			preMap[x][y] = 46 // 用字符 . 表示
		}
	}

	// 在地图上标记起点与终点
	preMap[F.x][F.y] = 70 // 字符 F = From
	preMap[T.x][T.y] = 84 // 字符 T = To

	// 在地图上标记障碍
	for _, v := range obstacle {
    
    
		preMap[v.x][v.y] = 88 // 用字符 X 表示
	}

	// 打印初始地图
	for y := 0; y <= rows-1; y++ {
    
    
		for x := 0; x <= cols-1; x++ {
    
    
			fmt.Printf("%c", preMap[x][y])
		}
		fmt.Printf("\n")
	}

	path := A_Star(preMap, F, T) // 开始寻径

	if path != nil {
    
     // 如找到路径则再次打印它:
		fmt.Println()
		fmt.Println("The path is as follow: ")
		// 在地图上标记障碍
		for _, v := range path {
    
    
			preMap[v.x][v.y] = 42 // 用字符 * 表示
		}
		for y := 0; y <= rows-1; y++ {
    
    
			for x := 0; x <= cols-1; x++ {
    
    
				fmt.Printf("%c", preMap[x][y])
			}
			fmt.Printf("\n")
		}
	}
}

// A*寻路主函数 preMap=地图 F=起点 T=终点
func A_Star(preMap [cols][rows]byte, F Point, T Point) []Point {
    
    
	var openList []Node
	var closeList []Node

	findPath := func() {
    
    
		//遍历 open list ,查找 F 值最小的节点,把它作为当前要处理的节点。
		curNode := findMin_Heap(openList)

		closeList = append(closeList, curNode)   // 将当前结点放入 closeList 中
		openList = deleteNode(openList, curNode) // 将 当前结点 从 openList 中删除

		// 遍历检测相邻节点的8个方向:NW N NE / W E / SW S SE
		direction := []Point{
    
    {
    
    -1, -1}, {
    
    0, -1}, {
    
    1, -1}, {
    
    -1, 0}, {
    
    1, 0}, {
    
    -1, 1}, {
    
    0, 1}, {
    
    1, 1}}

		for _, v := range direction {
    
     // 遍历基于父节点的8个方向
			var neighbour Node
			neighbour.coordinate.x = curNode.coordinate.x + v.x
			neighbour.coordinate.y = curNode.coordinate.y + v.y

			// 1. 是否超越地图边界?
			if (neighbour.coordinate.x < 0 || neighbour.coordinate.x >= cols) ||
				(neighbour.coordinate.y < 0 || neighbour.coordinate.y >= rows) {
    
    
				continue
			}

			// 2. 是否障碍物?
			if preMap[neighbour.coordinate.x][neighbour.coordinate.y] == 88 {
    
     // 88 = 字符 'X'
				continue
			}

			// 3. 自身是否已经存在于 closeList 中?
			if existNode(neighbour, closeList) != nil {
    
    
				continue
			}

			checkNode := existNode(neighbour, openList)
			// 这个邻居结点不在 openList 中
			if checkNode == nil {
    
    
				neighbour.parent = &curNode                                 // 当前结点设置为它的父结点
				d1 := curNode.g                                             // g = 当前结点到起点的距离
				d2 := calculate_G(neighbour.coordinate, curNode.coordinate) // 该邻居结点与当前结点的距离
				neighbour.g = d1 + d2
				neighbour.h = calculate_H(neighbour.coordinate, T) // h = 该邻居节点到终点的距离
				neighbour.f = neighbour.g + neighbour.h            // f = g + h
				openList = append(openList, neighbour)             // 把它加入 open list

			} else {
    
    
				// 该结点在 openList 中
				d1 := curNode.g + calculate_G(checkNode.coordinate, curNode.coordinate)
				d2 := checkNode.g

				if d1 < d2 {
    
     // 如果经由 curNode的路径更短,则将这个邻居的父节点指向 curNode 并更新 g,f
					// 在 Go 中,不允许使用指针直接修改切片元素, 需要遍历元素的下标
					index := 0
					for i, v := range openList {
    
    
						if neighbour.coordinate == v.coordinate {
    
    
							index = i
						}
					}
					openList[index].parent = &curNode
					openList[index].g = d1
					openList[index].f = neighbour.g + neighbour.h
				}
			}
		}
		// 观察每一轮结束后 openList 和 closeList 的变化:

	}

	start := time.Now() // 计时开始

	var fNode Node
	fNode.f = 0                        // 起点的优先级为0(最高)
	fNode.coordinate = F               // 起点坐标
	openList = append(openList, fNode) // 将起点装入 openList 中
	var tNode *Node
	var path []Point
	found := false

	for {
    
    
		if found {
    
     // 找到路径
			// 从终点指针开始反推路径:

			for {
    
    
				path = append(path, tNode.coordinate)
				if tNode.parent != nil {
    
    
					tNode = tNode.parent
				} else {
    
    
					break
				}
			}
			// 反转:从终点到起点,改为从起点到终点
			for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
    
    
				path[i], path[j] = path[j], path[i]
			}
			break
		}

		findPath() // 开始寻路

		for _, v := range openList {
    
     // 如果终点被包含在openList中,说明找到路径了
			if v.coordinate == T {
    
    
				tNode = &v
				found = true
				break
			}
		}

		if len(openList) == 0 {
    
     // openList耗尽,表示找不到路径
			found = false
			break
		}

	}
	duration := time.Since(start) // 计时结束
	fmt.Println("Running time:", duration)

	if found {
    
    
		fmt.Println("Path finding success!")
		return path
	} else {
    
    
		fmt.Println("No path was found!")
		return nil
	}

}

// 从 list 中删除一个结点
func deleteNode(list []Node, target Node) []Node {
    
    
	var newChain []Node
	for indexToRemove, v := range list {
    
    
		if v == target {
    
    
			newChain = append(list[:indexToRemove], list[indexToRemove+1:]...) // 从 openList中 移除 target
			break
		}
	}
	return newChain
}

// 判断节点是否存在于list中
func existNode(target Node, list []Node) *Node {
    
    
	for _, element := range list {
    
    
		if element.coordinate == target.coordinate {
    
     // 用XY值来判定唯一性
			return &element
		}
	}
	return nil
}

// 计算到终点的距离(H值)
func calculate_H(p1 Point, p2 Point) int {
    
    
	const D = 10 // 距离系数
	// 采用"曼哈顿距离(Manhattan distance)" :
	dx := int(math.Abs(float64(p1.x - p2.x)))
	dy := int(math.Abs(float64(p1.y - p2.y)))
	distance := (dx + dy) * D
	/*
		// 采用"切比雪夫距离(Chebyshev distance)":
		dx := math.Abs(float64(p1.x - p2.x))
		dy := math.Abs(float64(p1.y - p2.y))
		distance := int(math.Max(float64(dx), float64(dy)) * D)
	*/
	return distance
}

// 计算到起点的距离(G值)
func calculate_G(p1 Point, p2 Point) int {
    
    
	dx := math.Abs(float64(p1.x - p2.x))
	dy := math.Abs(float64(p1.y - p2.y))
	straight := int(math.Abs(dx - dy))
	bias := int(math.Min(float64(dx), float64(dy)))
	distance := straight*10 + bias*14
	return distance
}

// 寻找list中的最小结点(使用二叉堆方法)
func findMin_Heap(tree []Node) Node {
    
    
	var n = len(tree) - 1
	// 建小根堆 (percolate_down)
	for i := (n - 1) / 2; i >= 0; i-- {
    
    
		percolate_down(tree, n, i)
	}
	return tree[0]
}

// 建堆(下滤)
func percolate_down(tree []Node, size int, parent_node int) {
    
    
	left_node := 2*parent_node + 1
	right_node := 2*parent_node + 2

	var max_node = parent_node
	if left_node <= size && tree[left_node].f < tree[parent_node].f {
    
    
		max_node = left_node
	}
	if right_node <= size && tree[right_node].f < tree[max_node].f {
    
    
		max_node = right_node
	}

	if max_node != parent_node {
    
    
		tree[parent_node], tree[max_node] = tree[max_node], tree[parent_node]
		percolate_down(tree, size, max_node)
	}
}


5. Postscript

Still the same sentence, this article is only for the purpose of introductory ideas, and only serves as an introduction. To really apply the A-star algorithm to business programs, more learning is required. For example, a 1280*720 game map can be reduced to 128*72 first, and then binarized before A-star pathfinding efficiency will be much higher.

Guess you like

Origin blog.csdn.net/rockage/article/details/131773004