#

# 概括

  1. 由点的集合和边的集合构成

  2. 虽然存在有向图和无向图的概念,但实际上都可以用有向图来表达

  3. 边可能带有权值

难点

  • 算法的步骤不难,难的地方在于数据结构比较复杂
  • 相同的题目经常出现不同的数据结构来表达,所以一个算法可能不同题目有不同版本。

图示例

图-”森林“-示例

  • ”森林“:存在多个图,多个图之间没有链接

注意:图与二叉树的区别?

  • 二叉树点之间的连接有严格的关系要求,如:子节点不可以从自身出发连接父节点
  • 图的任意两个点之前都可以连接

# 无向图

以当前图为例子,任意连接的两点,从自身出发,都可以到连接的点,没有方向的要求,此时为无向图

# 有向图

上图表示有向图,点1可以到达点2,但是点2不可以到达点1

# 统一表示

我们使用有向图的表达方式,在单向的表达上新增一个回去的方向,此时与无向图的本质一致。

# 图的常用表达方式

  • 邻接表法
  • 邻接矩阵法

# 邻接表法

当前点 邻接点(点,权重)
A (B,6) 、 (E,15)
B (D, 51)
C
D (C, 6)、(B, 40)
E (D, 3)

# 邻接矩阵法

  • 横纵都为点
  • 表格内容为点到点的权重
  • ∞ 无穷表示,两点之间不相邻
A B C D E
A 0 6 15
B 0 51
C 0
D 40 6 0
E 3 0

# 推荐数据结构

#

  • 入度:可以到当前点的点数量
  • 出度:当前点可以到的点的数量
  • 直接相邻点:当前点可以到的点,在上图中则为 B、F
  • 以当前点为出发点的线:在上图中为A到B、F的两条线
// 点的结构体
type Node struct {
	Val   int  // 点的序号,可以是数值,也可以是 A,根据具体情况定
	In    int  // 入度
	Out   int  // 出度
	Edges []*Edge // 以当前点为出发点的线
	Nexts []*Node // 直接相邻点
}
1
2
3
4
5
6
7
8

# 线

  • 自身权重

  • 出发点

  • 目标点

// 边的结构体
type Edge struct {
	Weight  int    // 权重,如图则为 13
	From    *Node  // 出发点,如图则为 B
	To      *Node  // 目标点,如图则为 F
}
1
2
3
4
5
6

#

  • 图由点和边构成
  • 点使用唯一编号 映射
  • 边采用集合的方式表示
// 图的结构体
type Graph struct {
	Nodes map[int]*Node       // 点集
	Edges map[*Edge]struct{}  // 边集
}
1
2
3
4
5

# 数据结构转换

目前有一张图,关系如下图所示:

要求使用深度优先遍历与宽度优先遍历,来遍历这张图。

目前图的描述方式为一个二维数组,内容如下:

	chart := [][]int{
        // 第一个位置表示权重,
        // 第二个位置表示出发点的值
        // 第三个位置表示抵达点的值
		[]int{11, 1, 2},
		[]int{32, 1, 3}, 
		[]int{18, 1, 4}, 
		[]int{21, 2, 5},
		[]int{45, 2, 3},
		[]int{28, 3, 1},
		[]int{16, 3, 2},
		[]int{16, 3, 4},
		[]int{16, 3, 5},
	}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

此时我们发现,我们之前说的数据结构体用不上了,那为什么要定好之前的数据结构呢?有什么好处呢?

答:好处在于,我们可以使用稳定的数据结构去学习图相关的算法,图相关的算法是不变的,但是图的表达形式千遍万化,我们不可能针对不同的数据结构都写一边算法。

那我们怎么使用之前的数据结构体呢?

答:我们只需要对不同的数据结构,写一个数据结构转换的函数即可,将不同的表达方式转换为我们熟悉的数据结构,并在我们熟悉的数据结构上实现算法。

以下针对本次例子的图,写一个数据结构体转换函数,内容如下:

func GenerateGraph(chart [][]int) Graph {
    // chart 图的二维数组,原始表达方式
    
    // 创建图,初始化图集,与边集
	graph := Graph{map[int]*Node{}, map[*Edge]struct{}{}}
    
    // 遍历原始数据,并进行数据转换
	for _, v := range chart {
        
        // 取出权重
		weight := v[0]
        
        // 拿到出发点
		from := v[1]
        
        // 拿到目的点
		to   := v[2]
        
        // 如果图中的节点不包含,出发节点,则创建节点,加入点集
		if !Contains(graph.Nodes, from) {
			graph.Nodes[from] = &Node{Val:from}
		}
        
        // 如果图中的节点不包含目标点,则创建节点,加入点集
		if !Contains(graph.Nodes, to) {
			graph.Nodes[to] = &Node{Val:to}
		}
        
        // 拿到出发点
		fromN := graph.Nodes[from]
        // 拿到目标点
		toN   := graph.Nodes[to]
        // 创建新的边
		NewEdge := &Edge{weight, fromN, toN}
        
        // 出发点的出度+1
		fromN.Out++
        // 出发点的直接邻点添加当前目标点
		fromN.Nexts = append(fromN.Nexts, toN)
        // 出发点的直接邻边添加当前边
		fromN.Edges = append(fromN.Edges, NewEdge)
        
        // 目标点的入度+1
		toN.In++
        
        // 图的边集加入当前边
		graph.Edges[NewEdge] = struct{}{}
	}
	return graph
}

func Contains(set map[int]*Node, node int)  (ok bool) {
	_, ok = set[node]
	return
}
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

# 宽度优先遍历

  • 在图的宽度优先遍历中,同一个节点的直接相邻节点,之间的打印顺序,没有要求
  • 在宽度优先遍历中,先打印当前节点,并将相邻节点加入队列中,从队列取出节点打印,然后继续将当前节点的相邻节点加入队列,一直循环知道队列为空
  • 在图的数据结构中,两个点有可能互为直接相邻节点,所以需要预防死循环,需要加入set标记已经打印过的点

以下为宽度优先遍历的golang代码:

func GraphBFS(graph Graph){
    // graph 是我们经过转换的图
    
    // 创建队列
	queue := list.New()
    // 将1号节点放入队列中
	queue.PushBack(graph.Nodes[1])
    // 创建set标记加入过队列的节点
	nodeSet := map[*Node]struct{}{}
    // 标记1号已加入队列
	nodeSet[graph.Nodes[1]] = struct{}{}
	for queue.Len() > 0 {
        // 从队列取出节点
		node := queue.Remove(queue.Front()).(*Node)
        // 打印该节点
		fmt.Println(node.Val)
        // 遍历直接相邻节点
		for _, next := range node.Nexts {
            // 如果该节点没有进入过队列,则加入队列,并在set中标记该节点已进入队列
			if !Contains1(nodeSet, next) {
				queue.PushBack(next)
				nodeSet[next] = struct{}{}
			}
		}
	}
}

func Contains1(set map[*Node]struct{}, node *Node)  (ok bool) {
	_, ok = set[node]
	return
}
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

# 深度优先遍历

  • 深度优先遍历,使用栈结构记录沿途的路径,对于遍历到的位置直接打印,打印后加入栈中,并标记。
  • 循环从栈中取出节点,并遍历该节点的直接相邻节点,遍历过程中,如果遇到未进入过栈中的节点,则直接打印,并加入栈中,且跳出当前遍历
  • 继续循环,以上步骤,知道栈为空,则遍历完毕

深度优先遍历的代码如下:

func GraphDFS(graph Graph) {
    
    // set 标记打印过的节点
	nodeSet := map[*Node]struct{}{}
    // 创建栈,记录深度遍历的路径
	stack := list.New()
    
    // 打印当前节点
	fmt.Println(graph.Nodes[1].Val)
    // 栈中加入1号节点
	stack.PushBack(graph.Nodes[1])
    // set 标记加入过栈的节点
	nodeSet[graph.Nodes[1]] = struct{}{}

	for stack.Len() > 0 { // 栈不为空代表没有遍历完
        
        // 取出栈顶节点
		node := stack.Remove(stack.Back()).(*Node)
        
        // 遍历栈顶节点,如果当前节点存在,则遍历直接相邻节点
		for _, next := range node.Nexts {
            // 如果直接相邻节点已经打印过了,则直接跳过
			if !Contains1(nodeSet, next) {
                // 没有打印过,则打印
                fmt.Println(next.Val)
                // 加入set标记已打印
                nodeSet[next] = struct{}{}
				// 遍历路径恢复,并退出
				stack.PushBack(node)
				stack.PushBack(next)
				break
			}
		}
	}
}
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
上次更新: 9/3/2023,