笔者最近在工作中遇到了要实现“图”这种数据结构的情景,数据结构不扎实的笔者在一顿脸滚键盘的操作后还是记录一下自己是怎么在Unity里折腾出这个小可爱的o(* ̄▽ ̄*)ブ。
呼啦啦废话的折腾背景
首先笔者面临的需求是介样的(别想了肯定打了码):
有A,B,C,D…一堆的场景,这些场景内设置了跳转点,让其可以跳转到其他场景。但繁忙的策划大人目前对于这些跳转关系只决定了“一个场景只能跳转到指定的几个场景”,但还没决定这群场景之间的总的跳转关系,甚至这位策划大人表示,日后可能会介入任务或者任务状态系统,导致本来可通行的结点不能通行,或是本来不能通行的两个场景之间多加了一个跳转点。甚至日后等我们的美术大大金手指一开,还会批量增删一些场景和跳转关系。
笔者一听,又要动态加减场景,又要动态连接关系,这可咋整呢?总而言之,先画张图吧。按照上述需求,笔者把场景用圆,跳转关系用连线随手糊了张图一看:
艾玛,点+线的拓扑结构,这不是图嘛!
让我搜搜合适Unity用的数据结构库啊,这文就到这里啊( ̄▽ ̄)”
…开玩笑的。权当学习怎么着也得自己写一遍啊[表情]万一思维能容纳整个宇宙的策划大大忽然开出了库里不支持的操作咋办呢!(这个可能性很小)万一主程说用我们用的图要增删一些骚操作,要我优化咋整呢!(这个非常可能!)
图的实现
1、最简单的存储结构定义
图的结构定义相信各位大朋友们早就烂熟于心,小朋友们不懂的看我刚刚糊出来的插图记住“顶点“+“连线”即可。下面我主要是贴我怎么在Unity里实现它的代码。
我们先按照“图是顶点和顶点之间连线的合集”这个最简单的思路把图的最简代码定义撸出来:
/// <summary>
/// 顶点
/// </summary>
publicclassVertex
{
publicint id;//顶点编号
publicbool visited;//是否被遍历
}
/// <summary>
/// 连线
/// </summary>
publicclassEdge
{
publicint from;//从哪个顶点
publicint to;//指向哪个顶点
}
/// <summary>
/// 图
/// </summary>
publicclassGraph
{
publicList<Vertex> vertexs;//顶点集
publicList<Edge> edges;//连线集
}
这样,初步的图的数据描述、存储结构就定义好了。
接下来我们来按照需求实现对于这个数据结构的各种操作。
2、图的基本操作
1. 图中数据的查询
因为我们采用了列表List结构来进行数据存储,因此对于点、边的数据查询我们可以直接采用.Net库System.Collections.Generic;中的List的查询方法直接返回列表查找的结果。例如:
/// <summary>
/// 通过ID找到顶点
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public Vertex FindVertex(int id)
{
return vertexs.Find(v => v.id == id);
}
/// <summary>
/// 通过条件匹配找到顶点
/// </summary>
/// <param name="match"></param>
/// <returns></returns>
public Vertex FindVertex(Predicate<Vertex> match)
{
return vertexs.Find(match);
}
/// <summary>
/// 通过匹配找到当前边
/// </summary>
/// <param name="match"></param>
/// <returns></returns>
public Edge FindEdge(Predicate<Edge> match)
{
return edges.Find(match);
}
2. 图的遍历:深度遍历与广度遍历
图的遍历与树的遍历概念类似,即把图中所有的顶点不重复地访问一遍。按照图的特性,图的遍历通常从其中的某一个顶点开始。
在遍历开始前,我们先把所有的顶点都设成未被遍历的状态:
/// <summary>
/// 清除遍历记号
/// </summary>
private static void InitVisited(Graph graph)
{
foreach (Vertex vertex in graph.vertexs)
{
vertex.visited = false;
}
}
深度优先遍历:该遍历方法所遵循的规则是,从当前顶点,沿着自己身上的第一条连线开始向下访问,若没有碰到重复顶点,继续取前顶点第一条边的方向向下访问,若碰到重复顶点,访问下一条边所连接的顶点,依次类推,直到把所有非浮动顶点访问完毕。
代码实现:递归访问
/// <summary>
/// 深度优先遍历
/// </summary>
/// <param name="callback"></param>
public static void DFSTraverse(Graph graph, int start)
{
Vertex v = graph.FindVertex(start);
if (v == null)
{
throw new ArgumentException(start + " 顶点并不存在!");
}
InitVisited(graph);
DFS(graph, v); //从第一个顶点开始遍历
}
/// <summary>
/// 使用递归进行深度优先遍历
/// </summary>
/// <param name="v"></param>
/// <param name="callback"></param>
private static void DFS(Graph graph, Vertex v)
{
v.visited = true; //将访问标志设为true
Console.WriteLine(v.id);//访问
List<Edge> edges = graph.FindEdges(e => e.from == v.id);
//访问此顶点的所有邻接点
for (int i = 0; i < edges.Count; i++)
{
Vertex nextv = graph.FindVertex(edges[i].to);
//如果邻接点未被访问,则递归访问它的边
if (nextv != null && !nextv.visited)
{
DFS(graph, nextv); //递归
};
}
}
广度优先遍历:该遍历方法是,从当前顶点开始访问,先把其邻接顶点全部访问一遍,再按照顺序访问接下来的顶点的邻接顶点。
代码访问方式:队列辅助访问
/// <summary>
/// 广度优先遍历
/// </summary>
/// <param name="callback">from, to, edge</param>
public static void BFSTraverse(Graph graph, int start)
{
Vertex v = graph.FindVertex(start);
if (v == null)
{
throw new ArgumentException(start + " 顶点并不存在!");
}
BFS(graph, v); //从第一个顶点开始遍历
}
/// <summary>
/// 使用队列进行广度优先遍历
/// </summary>
/// <param name="startv"></param>
/// <param name="callback">from,to,edge</param>
private static void BFS(Graph graph, Vertex startv)
{
InitVisited(graph); //将visited标志全部置为false
Queue<Vertex> queue = new Queue<Vertex>();//队列遍历
startv.visited = true; //设置访问标志
int index = 0;//设置访问顺序
Console.WriteLine(startv.id);//访问
queue.Enqueue(startv); //进队
while (queue.Count > 0) //只要队不为空就循环
{
Vertex w = queue.Dequeue();
List<Edge> edges = graph.FindEdges(e => e.from == w.id);
//访问此顶点的所有邻接点
for (int i = 0; i < edges.Count; i++)
{
Vertex nextv = graph.FindVertex(edges[i].to);
//如果邻接点未被访问,则递归访问它的边
if (nextv != null && !nextv.visited)
{
nextv.visited = true; //设置访问标志
index++;
Console.WriteLine(nextv.id);//访问
queue.Enqueue(nextv); //进队
}
}
}
}
3、 根据算法需求优化数据存储结构
1、 改顶点、连线分别存储为 顶点、连线组成的邻接表存储。
在上述存储结构中,每次对于某条边的查询都要进行查询,每次查询都需要O(n^2)的时间。而改成邻接表后,找邻接点所需的时间取决于顶点和边的数量,是O(n+e),可以大大提高时间效率。
更改后,邻接表中的边也不必再存储其起点信息,可以再压缩一点点图的空间大小。
/// <summary>
/// 邻接表顶点
/// </summary>
public class Vertex
{
public int id;//顶点编号
public bool visited;//是否被遍历
public List<Edge> edges;
}
/// <summary>
/// 连线
/// </summary>
public class Edge
{
public int link;//指向哪个顶点
}
/// <summary>
/// 图
/// </summary>
public class Graph
{
public List<Vertex> vertexs;//邻接表头集
}
图的泛型扩展
在实际操作中,我们会遇到对于顶点ID是取int型还是string型,顶点内存储数据的类型和边上的附加属性(比如权)的类型 的自定义情况。总之为了让“图”这个数据结构能被泛化使用,我们得把它弄成和List<T>一样的泛型结构。因此,我们做出如下处理:
/// <summary>
/// 邻接表顶点
/// </summary>
public class Vertex<Tid, Tv, Te>
{
public Tid id;//顶点编号
public bool visited;//是否被遍历
public Tv data;
public List<Edge<Tid, Te>> edges;
}
/// <summary>
/// 连线
/// </summary>
public class Edge<Tid, Te>
{
public Tid link;//指向哪个顶点
public Te weight;
}
/// <summary>
/// 图
/// </summary>
public class Graph<Tid, Tv, Te>
{
public List<Vertex<Tid, Tv, Te>> vertexs;//邻接表头集
}
这样,图结构的顶点和边数据中的各项属性就能被我们自定义了。
Unity 内的可视化编辑
解决了上述泛型问题后,让我们愉快地来在Unity里使用它吧!我们先看看在编辑器里对一个图数据进行赋值!
来让我们看看id为字符串,储存了GameObject,边上权为浮点型的图长什么样吧!
代码定义:
[Serializable]
public class SampleGraph : Graph<string,GameObject,float>{}
public class SampleGraphRoot : MonoBehaviour
{
public SampleGraph graph;
}
Editor展示:
…欸?咋不显示呢?明明List<GameObject>,List<int>这类泛型后的数据都能直接使用编辑器赋值啊,难不成我还得另写txt或exel读写代码,或自定义编辑器来配合这个数据结构?太麻烦了吧…Unity编辑器不是可以对自定义类型进行Serialize化么?
经过多方尝试,笔者发现,想要在Unity编辑器里对自定义的数据类型进行可视化操作,除了可视化本身类,还要把其内部引用到的数据结构显示申明序列化。于是,虽然麻烦,我们就把内部的泛型顶点类和泛型边类也给显示申明序列化吧:
代码定义:
/// <summary>
/// 带权有向图
/// </summary>
/// <typeparam name="Tv">顶点里存的数据</typeparam>
/// <typeparam name="Te">边上存的数据</typeparam>
[Serializable]
public class DirectedGraph<Tid, Tv, Te, TV, TE> where TV : GraphVertex<Tid, Tv, Te, TE> where TE : GraphEdge<Tid, Te>
{
/// <summary>
/// 图的所有顶点
/// </summary>
public List<TV> vertexs;
}
/// <summary>
/// 顶点结构体
/// </summary>
[Serializable]
public class GraphVertex<Tid, TV, TE, TEdge> where TEdge : GraphEdge<Tid, TE>
{
public Tid id;//顶点id,全图唯一
public TV data;
public List<TEdge> edges; //邻接点链表头指针
public Boolean visited; //访问标志,遍历时使用
}
/// <summary>
/// 边结构体
/// </summary>
[Serializable]
public class GraphEdge<Tid, TE>
{
public Tid link;
public TE weight;
}
Editor内显示结果:
大功告成!开心~o(* ̄▽ ̄*)ブ