A星寻路算法(四):二叉树与Heap

Number of views 70

我们在寻路逻辑中对开放集合使用了for循环遍历,这种方式的时间复杂度为 O(n),实现的逻辑代码如下:

for (int i = 1; i < openSet.Count; i ++) {
    if (openSet[i].fCost < node.fCost || (openSet[i].fCost == node.fCost && openSet[i].hCost < node.hCost)) {
        node = openSet[i];
    }
}

这节主要讲解如何更快的进行遍历,以及遍历所使用的方法,我们会使用Heap (堆)的概念进行逻辑处理。

我们可以将Heap想象成为一种类似二叉树的数据结构。每个节点都拥有自己的两个子节点。各自的子节点又有对应的两个子节点,这样以此类推。

image1746868370847.png

需要注意的是在实际代码中,将使用数组表示。元素的索引按从左到右的顺序排列。但是在A星寻路算法中我们针对的是FCost进行的数据存储与查询。如下图:

image1746869106950.png

针对该FCost的存储形式,父节点的FCost需要比子节点的FCost小才能满足该存储规则。假设我们要在节点FCost为10的节点下新增了FCost为5的节点。这时候就需要重新排序,因为它比父节点的FCost还小,这是不被允许的:

image1746869570873.png

所以我们把5跟10的位置交换:

image1746870297255.png

发现5还是比7小,所以还是得选择继续交换。

image1746870413644.png

如果我们要取出FCost最小的那个节点,这时候该种存储数据方式的优势就显现出来了。我们只需要取出第一个节点(FCost为4)即可,因为该节点位于Heap的顶部,取出后的节点用最后一个节点补足:

image1746877038696.png

接着我们还是要与它的子节点的FCost进行比较,确保父节点的FCost小于子节点。如果对比的结果比子节点小就需要与子节点交换。交换的结果如下:

image1746879355829.png

这是一种快速的排序方式,我们只需要在排序过程中完成对其中一个节点侧的排序,对另一侧已完成的节点序可以不需要做任何修改。

上面是把FCost直接代入对应索引所对应的节点,以方便说明该种方式的优越性。现在切换回索引节点图,如果我们的FCost已经全部排序就绪,如何根据索引求取当前节点的父节点或两个子节点的索引,毕竟数组的存储是从左到右的顺序存储的:

image1746868370847.png

假设我们要求取索引11的父节点:

image1746881549004.png

只需要把 (11-1)/2,推导出一般性公式则为:

$$
Node_p=floor((n-1)/2)
$$

  • 要求取索引11的父节点,只需要让其减1的结果再除以2,等于5。
  • 要求取索引12的父节点也一样,只需要让其减1的结果再除以2,等于5.5,需要向下取整等于5。

如果已知节点的索引是5,要求取左子节点与右子节点呢?

求取左子节点的公式为:

$$
Node_{childL}=2*n+1
$$

求取右子节点的公式为:

$$
Node_{childR}=2*n+2
$$

实现Heap:

using UnityEngine;
using System.Collections;
using System;

/// <summary>
/// 泛型最小堆类,用于高效维护优先队列
/// 泛型约束:T 必须实现 IHeapItem<T> 接口(提供 HeapIndex 和 CompareTo 方法)
/// </summary>
public class Heap<T> where T : IHeapItem<T> {

	// 存储堆元素的数组
	private T[] items;
	// 当前堆中元素数量
	private int currentItemCount;

	/// <summary>
	/// 构造函数:初始化堆的最大容量
	/// </summary>
	/// <param name="maxHeapSize">堆的最大容量</param>
	public Heap(int maxHeapSize) {
		items = new T[maxHeapSize];
	}

	/// <summary>
	/// 向堆中添加新元素
	/// 将元素放在数组末尾,然后通过向上排序(SortUp)维护堆结构
	/// </summary>
	/// <param name="item">要添加的元素</param>
	public void Add(T item) {
		item.HeapIndex = currentItemCount; // 设置元素在堆中的索引
		items[currentItemCount] = item;    // 将元素放入数组末尾
		SortUp(item);                      // 向上排序以维护堆结构
		currentItemCount++;                // 元素计数加1
	}

	/// <summary>
	/// 移除并返回堆顶元素(最小元素)
	/// 将数组最后一个元素移动到堆顶,然后通过向下排序(SortDown)维护堆结构
	/// </summary>
	/// <returns>堆顶元素</returns>
	public T RemoveFirst() {
		// 保存堆顶元素
		T firstItem = items[0];
		// 元素计数减1
		currentItemCount--;
		// 将最后一个元素移动到堆顶
		items[0] = items[currentItemCount];
		// 更新堆顶元素的索引
		items[0].HeapIndex = 0;
		// 向下排序以维护堆结构
		SortDown(items[0]);
		// 返回原堆顶元素
		return firstItem;
	}

	/// <summary>
	/// 更新堆中的元素位置(当元素的优先级变化时调用)
	/// 通过向上排序(SortUp)调整元素位置
	/// </summary>
	/// <param name="item">需要更新的元素</param>
	public void UpdateItem(T item) {
		SortUp(item);
	}

	/// <summary>
	/// 获取当前堆中元素数量
	/// </summary>
	public int Count {
		get {
			return currentItemCount;
		}
	}

	/// <summary>
	/// 检查堆是否包含指定元素
	/// 通过比较元素的 HeapIndex 和实际值进行验证
	/// </summary>
	/// <param name="item">要查找的元素</param>
	/// <returns>是否包含该元素</returns>
	public bool Contains(T item) {
		return Equals(items[item.HeapIndex], item);
	}

	/// <summary>
	/// 向下排序(从父节点到子节点)
	/// 维护堆的最小堆性质(父节点 <= 子节点)
	/// </summary>
	/// <param name="item">当前需要排序的节点</param>
	void SortDown(T item) {
		while (true) {
			// 计算左右子节点的索引
			int childIndexLeft = item.HeapIndex * 2 + 1;
			int childIndexRight = item.HeapIndex * 2 + 2;
			int swapIndex = 0;

			// 如果左子节点在堆范围内
			if (childIndexLeft < currentItemCount) {
				// 默认选择左子节点
				swapIndex = childIndexLeft;

				// 如果右子节点也在范围内
				if (childIndexRight < currentItemCount) {
					// 选择较小的子节点
					if (items[childIndexLeft].CompareTo(items[childIndexRight]) < 0) {
						swapIndex = childIndexRight;
					}
				}

				// 如果当前节点大于选中的子节点,则交换
				if (item.CompareTo(items[swapIndex]) < 0) {
					Swap(item, items[swapIndex]);
				}
				else {
					// 否则排序完成
					return;
				}

			}
			else {
				// 没有子节点,排序完成
				return;
			}

		}
	}

	/// <summary>
	/// 向上排序(从子节点到父节点)
	/// 维护堆的最小堆性质(父节点 <= 子节点)
	/// </summary>
	/// <param name="item">当前需要排序的节点</param>
	void SortUp(T item) {
		// 计算父节点的索引
		int parentIndex = (item.HeapIndex-1)/2;
	
		// 不断比较并交换,直到满足堆性质或到达根节点
		while (true) {
			// 获取父节点
			T parentItem = items[parentIndex];
			// 如果当前节点大于父节点,则交换
			if (item.CompareTo(parentItem) > 0) {
				Swap(item, parentItem);
			}
			else {
				// 否则排序完成
				break;
			}

			// 更新父节点索引
			parentIndex = (item.HeapIndex-1)/2;
		}
	}

	/// <summary>
	/// 交换两个元素的位置,并更新它们的 HeapIndex
	/// </summary>
	/// <param name="itemA">元素A</param>
	/// <param name="itemB">元素B</param>
	void Swap(T itemA, T itemB) {
		// 交换数组中的位置
		items[itemA.HeapIndex] = itemB;
		items[itemB.HeapIndex] = itemA;
		// 更新元素的 HeapIndex
		int itemAIndex = itemA.HeapIndex;
		itemA.HeapIndex = itemB.HeapIndex;
		itemB.HeapIndex = itemAIndex;
	}
}

/// <summary>
/// 堆元素接口,定义堆操作所需的基本功能
/// </summary>
public interface IHeapItem<T> : IComparable<T> {
	/// <summary>
	/// 获取或设置该元素在堆中的索引位置
	/// </summary>
	int HeapIndex {
		get;
		set;
	}
}

我们还需要修改下PathFinding的逻辑,把开放集修改为Heap存储:

/// <summary>
/// A* 寻路算法主函数
/// </summary>
/// <param name="startPos">起点世界坐标</param>
/// <param name="targetPos">终点世界坐标</param>
void FindPath(Vector3 startPos, Vector3 targetPos) {
    // 将世界坐标转换为对应的网格节点
    Node startNode = grid.NodeFromWorldPoint(startPos);
    Node targetNode = grid.NodeFromWorldPoint(targetPos);

    // 初始化开放集(使用最小堆实现)和关闭集
    Heap<Node> openSet = new Heap<Node>(grid.MaxSize); // 堆容量设为网格最大节点数
    HashSet<Node> closedSet = new HashSet<Node>();
    openSet.Add(startNode); // 将起点加入开放集

    // 主循环:持续搜索直到开放集为空
    while (openSet.Count > 0) {
        // 从堆中取出 fCost 最小的节点(A* 算法核心)
        Node currentNode = openSet.RemoveFirst();
        closedSet.Add(currentNode); // 标记当前节点为已访问

        // 如果找到目标节点,回溯路径并结束搜索
        if (currentNode == targetNode) {
            RetracePath(startNode, targetNode);
            return;
        }

        // 遍历当前节点的所有邻居节点
        foreach (Node neighbour in grid.GetNeighbours(currentNode)) {
            // 跳过不可行走的节点或已访问的节点
            if (!neighbour.walkable || closedSet.Contains(neighbour)) {
                continue;
            }

            // 计算从起点到邻居的新路径成本
            int newMovementCostToNeighbour = currentNode.gCost + GetDistance(currentNode, neighbour);
          
            // 如果新路径成本更低,或邻居尚未加入开放集
            if (newMovementCostToNeighbour < neighbour.gCost || !openSet.Contains(neighbour)) {
                // 更新邻居节点的成本和父节点
                neighbour.gCost = newMovementCostToNeighbour;
                neighbour.hCost = GetDistance(neighbour, targetNode);
                neighbour.parent = currentNode;

                // 将邻居加入开放集(如果尚未在其中)
                if (!openSet.Contains(neighbour))
                    openSet.Add(neighbour);
                else {
                    // 以下代码被注释:当节点已存在于堆中时,应调用 UpdateItem 通知堆更新该节点的位置
                    // openSet.UpdateItem(neighbour);
                }
            }
        }
    }
}

还需要让Node实现IHeapItem接口,让其具备CompareTo与HeapIndex能力:

using UnityEngine;
using System.Collections;

/// <summary>
/// 表示网格中的一个节点,用于A*寻路算法
/// 实现 IHeapItem<Node> 接口以支持堆操作
/// </summary>
public class Node : IHeapItem<Node> {

	/// <summary>
	/// 节点是否可行走(障碍物不可行走)
	/// </summary>
	public bool walkable;

	/// <summary>
	/// 节点在世界坐标系中的位置
	/// </summary>
	public Vector3 worldPosition;

	/// <summary>
	/// 节点在网格中的X轴索引
	/// </summary>
	public int gridX;

	/// <summary>
	/// 节点在网格中的Y轴索引
	/// </summary>
	public int gridY;

	/// <summary>
	/// 从起点到当前节点的实际移动成本(g值)
	/// </summary>
	public int gCost;

	/// <summary>
	/// 从当前节点到目标节点的启发式估计成本(h值)
	/// </summary>
	public int hCost;

	/// <summary>
	/// 当前节点的父节点,用于路径回溯
	/// </summary>
	public Node parent;

	/// <summary>
	/// 节点在堆中的索引位置(由堆维护)
	/// </summary>
	int heapIndex;

	/// <summary>
	/// 节点构造函数
	/// </summary>
	/// <param name="_walkable">是否可行走</param>
	/// <param name="_worldPos">世界坐标位置</param>
	/// <param name="_gridX">网格X索引</param>
	/// <param name="_gridY">网格Y索引</param>
	public Node(bool _walkable, Vector3 _worldPos, int _gridX, int _gridY) {
		walkable = _walkable;
		worldPosition = _worldPos;
		gridX = _gridX;
		gridY = _gridY;
	}

	/// <summary>
	/// 获取节点的总评估成本 f = g + h
	/// </summary>
	public int fCost {
		get {
			return gCost + hCost;
		}
	}

	/// <summary>
	/// 获取或设置节点在堆中的索引位置
	/// </summary>
	public int HeapIndex {
		get {
			return heapIndex;
		}
		set {
			heapIndex = value;
		}
	}

	/// <summary>
	/// 比较当前节点与另一个节点的优先级(用于堆排序)
	/// 返回值规则:
	/// - 负值:当前节点应排在参数节点之前
	/// - 正值:当前节点应排在参数节点之后
	/// - 0:优先级相同
	/// 
	/// 注意:返回值取反(-compare)是为了配合最小堆实现
	/// 实际比较逻辑为:优先级由 fCost 升序,若 fCost 相同则按 hCost 升序
	/// </summary>
	/// <param name="nodeToCompare">要比较的节点</param>
	/// <returns>比较结果</returns>
	public int CompareTo(Node nodeToCompare) {
		int compare = fCost.CompareTo(nodeToCompare.fCost);
		if (compare == 0) {
			compare = hCost.CompareTo(nodeToCompare.hCost);
		}
		return -compare; // 取反以适配最小堆排序(fCost 小的节点优先)
	}
}

通过Heap的方式优化后,我们的寻路逻辑比之前更加快速。由之前的平均25ms的计算时间,缩短至5ms。并且随着地图的增大,优化的效率会越明显。

image1746933731397.png

0 Answers