Go Book / 5 Data Structure & Algorithms / 06 基本数据结构:图

06 基本数据结构:图

图的概述

在众多数据结构中,图应该是最复杂的数据结构了,要了解图的全貌需要较深厚的数学基础,这里不可能在一篇文章内讲完,所以只做抛砖引玉。

图的基本概念

图的两种形式.png

  • 有向图:图G中的每条边都是有方向的,则称G为有向图(Digraph)。在有向图中,一条有向边是由两个顶点组成的有序对,有序对通常用尖括号表示。有向边也被称为弧,将边的始点称为弧尾,将终点称为弧头。

  • 无向图:如果图中的每条边都是没有方向的,这种图被称为无向图。无向图中的边都是顶点的无序对,通常用圆括号来表示无序对。

  • 顶点:通常将图中的数据元素称为顶点(Vertex),通常用V来表示顶点的集合。

  • 完全图:如果无向图中的任意两个顶点之间都存在着一条边,则将此无向图称为无向完全图。如果有向图中的任意两个顶点之间都存在着方向相反的两条弧,则将此有向图称为有向完全图。

  • 稠密图与稀疏图:当一个图接近完全图时被称为稠密图,反之将含有较少的边数(即当e«n(n-1))的图称为稀疏图。

  • 权和网:图中的每一条边(弧)都可以有一个相关的数值,将这种与边相关的数值称为权。权能够表示从一个顶点到另一个顶点的距离或花费的代价。边上带有权的图称为带权图,也称作网。

  • 子图:假设存在两个图G=(V,E)和G'=(V',E'),如果V’是V的子集(即V' V),并且E’是E的子集(即E' E),则称G’是G的子图。

  • 邻接点:在无向图G=(V,E)中,如果边(vi ,vj )∈E,则称顶点vi 和vj 互为邻接点(Adjacent);边(vi ,vj )依附于顶点vi 和vj ,即vi 和vj 相关联。

  • 顶点的度:顶点的度是指和顶点相关联的边的数量。在有向图中,以顶点vi 为弧尾的弧的值称为顶点vi 的出度,以顶点vi 为弧头的弧的值称为顶点vi 的入度,顶点vi 的入度与出度的和是顶点vi 的度。

  • 路径:如果图中存在一个从顶点vi 到顶点vj 的顶点序列,则这个顶点序列被称为路径。在图中有如下两种路径。 -(1)简单路径:指路径中的顶点不重复出现。 -(2)回路或环:是指如果路径中除第一个顶点和最后一个顶点相同以外,其余顶点不重复。一条路径上经过的边的数目称为路径长度。

除了以上概念还有其他,感兴趣的可继续深入学习,这里点到即止

图的存储结构

数据结构的最终目的是存储数据,所以我们在研究图的时候,需要更加深入地研究“图”的存储结构。关于图的存储结构,除了存储图中各个顶点本身的信息之外,还要存储顶点之间的所有关系。在图中的常用存储结构有3种,分别是邻接矩阵、邻接表和十字链表。

1.邻接矩阵表示法

邻接矩阵表示法.png

邻接矩阵表示顶点直接的相邻关系,推出邻接矩阵的目的是表示一种关系。表示这种关系的方法非常简单,具体表示方法如下所示。

  • 用一个一维数组存放顶点信息。
  • 用一个二维数组表示n个顶点之间的关系”
2.邻接表表示法

虽然邻接矩阵比较简单,只需要使用二维数组即可实现存取操作。但是除了完全图之外,其他图的任意两个顶点并不都是相邻接的,所以邻接矩阵中有很多零元素,特别是当n较大,并且边数和完全图的边(n-1)相比很少时,邻接矩阵会非常稀疏,这样会浪费存储空间。为了解决这个问题,此时邻接表便光荣地登上了历史的舞台。

邻接表是由邻接矩阵改造而来的一种链接结构,因为它只考虑非零元素,所以就节省了零元素所占的存储空间。邻接矩阵的每一行都有一个线性链接表,链接表的表头对应着邻接矩阵该行的顶点,链接表中的每个节点对应着邻接矩阵中该行的一个非零元素。

以下用go代码实现邻接表表示法:

// 顶点数据结构体
type vertext struct {
	key   string
	value interface{}
}

// 图结构体
type Graph struct {
	vers  []vertext           // 存储图所有的顶点
	edges map[string][]string // 存储各个顶点所有邻接的边
}
3.邻接矩阵与邻接表的比较

(1)在邻接表中,每个线性链接表中各个节点的顺序是任意的。 (2)只使用邻接表中的各个线性链接表,不能说明它们顶点之间的邻接关系。 (3)在无向图中,某个顶点的度数等于该顶点对应的线性链接表的节点数;在有向图中,某个顶点的“出度”数等于该顶点对应的线性链表的节点数。

邻接矩阵与邻接表对比.png

T1 (n)是指在输入边/弧时,输入的顶点信息是顶点的编号;而T2 (n)是指在输入边/弧时,输入的是顶点本身的信息,此时需要查找顶点在图中的位置。

最后还有一种邻接矩阵与邻接表相结合的表示方法:十字链表法,这里不再展开,感兴趣的可继续深入了解。

图的遍历

图的遍历是指从图中的某个顶点出发,按照某种方法对图中的所有顶点访问且仅访问一次。为了保证图中的各顶点在遍历过程中仅访问一次,需要为每个顶点设一个访问标志。例如可以为图设置一个访问标志数组visited[n],用于标示图中每个顶点是否被访问过,其初始值为0(“假”)。如果访问过顶点vi ,则设置visited[i]为1(“真”)。图的遍历分为两种,分别是深度优先搜索(Depth-First Search,DFS)和广度优先搜索(Breadth-First Search,BFS)。

注意:图的遍历工作要比树的遍历工作复杂,这是因为图中的顶点关系是任意的,这说明图中顶点之间是多对多的关系,并且图中还可能有回路存在,所以在访问某个顶点后,可能沿着某条路径搜索后又回到该顶点上。

1.深度优先遍历(DFS)

深度优先搜索的过程,是对每一个可能的分支路径深入到不能再深入为止的过程,并且每个节点只能访问一次。

图的深度优先遍历类似于二叉树的深度优先遍历,其基本思想是:从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到。显然,这是一个递归的搜索过程。

深度优先遍历.png

以上图为例,假定V1是出发点,首先访问V1。这时两个邻接点V2、V3均未被访问,可以选择V2作为新的出发点,访问V2之后,再找到V2的未访问过的邻接点。同V2邻接的有V1、V4和V5,其中V1已经访问过了,可以选择V4作为新的出发点。重复上述搜索过程,继续依次访问V8、V5。访问V5之后,由于与V5相邻的顶点均已被访问过,搜索退回到V8,访问V8的另一个邻接点V6.接下来依次访问V3和V7,最后得到的访问序列为V1→V2→V4→V8→V5→V6→V3→V7。

1.广度优先遍历(BFS)

图的广度优先遍历算法是一个分层遍历的过程,和二叉树的广度优先遍历类似,其基本思想在于:从图中的某一个顶点Vi触发,访问此顶点后,依次访问Vi的各个为层访问过的邻接点,然后分别从这些邻接点出发,直至图中所有顶点都被访问到。

广度优先遍历.png

  对于上图所示的无向连通图,若从顶点V1开始,则广度优先遍历的顶点访问顺序是V1→V2→V3→V4→V5→V6→V7→V8。

Go实现图数据结构及常用操作

这里用常用的邻接表表示法


type vertext struct {
	key   string
	value interface{}
}

type Graph struct {
	vers  []vertext           // 存储图所有的顶点
	edges map[string][]string // 存储各个顶点所有邻接的边
}

// 顶点是否已存在
func (g *Graph) IsVertextExist(k string) bool {
	for _, item := range g.vers {
		if k == item.key {
			return true
		}
	}
	return false
}

// 添加顶点
func (g *Graph) AddVertext(k string, v interface{}) bool {
	if g.IsVertextExist(k) {
		return false
	}
	g.vers = append(g.vers, vertext{k, v})
	g.edges[k] = make([]string, 0)
	return true
}

// 添加边
func (g *Graph) AddEdge(k1, k2 string) bool {
	if g.IsVertextExist(k1) && g.IsVertextExist(k2) {
		g.edges[k1] = append(g.edges[k1], k2)
		g.edges[k2] = append(g.edges[k2], k1)
		return true
	}
	return false
}

// 移除顶点
func (g *Graph) RemoveVertext(k string) bool {
	isExist := false
	count := len(g.vers)
	for i := 0; i < count; i++ {
		if g.vers[i].key == k {
			g.vers = append(g.vers[:i], g.vers[i+1:]...)
			isExist = true
			break
		}
	}
	if !isExist {
		return false
	}

	delete(g.edges, k)
	for key, slice := range g.edges {
		sCount := len(slice)
		for i := 0; i < sCount; i++ {
			if slice[i] == k {
				g.edges[key] = append(g.edges[key][:i], g.edges[key][i+1:]...)
				break
			}
		}
	}

	return true
}

// 移除边
func (g *Graph) RemoveEdges(k1, k2 string) bool {
	count := len(g.edges[k1])
	for i := 0; i < count; i++ {
		if g.edges[k1][i] == k2 {
			g.edges[k1] = append(g.edges[k1][:i], g.edges[k1][i+1:]...)
			return true
		}
	}

	return false
}

// 广度优先搜索:类似树的层级遍历,使用队列
func (g *Graph) BFS(startVerKey string, handler func(verKey string)) {

	// 1.队列容器
	q := queue.NewLinkQueue(len(g.vers))

	// 2.初始化颜色
	colors := g.initColors()

	// 3.将初始端点加入队列
	q.Enqueue(startVerKey)

	// 4.循环从队列中获取元素
	for {
		// 4.1从队列获取端点
		element := q.Dequeue()
                if element == nil {
			break
		}
		ver := element.(string)

		// 4.2 获取和该端点相连的其他端点
		vEdges := g.edges[ver]

		// 4.3 将该端点的颜色设置成灰色:已探索
		colors[ver] = "grey"

		// 4.4 遍历该端点的其他相连端点,进入队列,并设置灰色:已探索
		for _, item := range vEdges {
			if colors[item] == "white" {
				colors[item] = "grey"
				q.Enqueue(item)
			}
		}
		// 4.5 访问端点,并将端点设置成黑色:已访问
		handler(ver)
		colors[ver] = "black"
	}

}


// 初始化图端点颜色:未访问未探索:白色;未访问已探索:灰色;已访问:黑色
func (g *Graph) initColors() map[string]string {
	colors := make(map[string]string)
	for _, v := range g.vers {
		colors[v.key] = "white"
	}
	return colors
}

// 深度优先搜索
func (g *Graph) DFS(startVerKey string, handler func(string)) {
	// 1.初始化颜色
	colors := g.initColors()
	// 2. 开始递归遍历
	g.dfs(startVerKey,colors,handler)
}

// 深度优先搜索内部递归方法
func (g *Graph) dfs(vKey string,colors map[string]string ,handler func(string))  {
	// 1.将该端点颜色设置成灰色
	colors[vKey] = "grey"

	// 2.访问并处理该端点
	handler(vKey)

	// 3.遍历探索与该节点相连的其他节点
	vEdges := g.edges[vKey]
	for _,item := range vEdges{
		if colors[item] == "white" {
			// 递归遍历
			g.dfs(item,colors,handler)
		}
	}

	// 4. 将该节点设置成已访问
	colors[vKey] = "black"
}