“您好,我是XX快递,您有一个包裹等待签收”,快递员总是会给我们带来惊喜。
敬业的快递小哥将包裹安全送达到你的手中,然后启程去送下一份包裹,每一天都走遍无数的大街小巷。
忘忧今天与大家聊的话题,就是快递员走过的路。
什么是图
在数据结构中,树是一种一对多(节点与节点)的非线性数据结构,节点间有明确的层级关系,而图则是一种多对多(顶点与顶点)的非线性数据结构,顶点之间不存在父子关系。
有向图和无向图
图按照顶点之间的连通性,可分为有向图和无向图。
- 无向图:指的是顶点之间的连接没有方向,如快递小哥的路线图中,快递小哥既可以从分治小区走向A*小区,同样又可以从A*小区走向分治小区。
- 有向图:假设快递小哥从队列公寓走向栈公寓的路是单向通道,只允许从队列公寓走向栈公寓,但不允许从栈公寓返回队列公寓,此时的这个路线图就是有向的。
相关术语
- 顶点: 类似于树中的节点,在图中,每一个元素都被称之为顶点。
- 边:顶点与顶点之间的关联关系称之为边。在一个有n个顶点的图中,边的数量一定大于等于n-1。
图的存储
图的存储,有比较常用的两种方式,第一种是邻接矩阵法,他用一个n*n(顶点个数)的矩阵来表示每两个顶点间的关系,具体表示方式如下图所示:
邻接矩阵中,用[A][B] = 1来表示A可以通向B,用[A][B] = 0来表示A不可以通向B;在无向图中,A和B互通,则矩阵的[A][B]位置和[B][A]位置均为1,在无向图中,如果A可以到达B,但是B不可以到达A,则[A][B] = 1, [B][A] = 0;仔细观察可以发现,无向图的矩阵是延着自左上到右下的对角线对称的。
邻接矩阵表示法固然可以清晰表达每个顶点之间的关系,但是却存在一个问题,当顶点之间存在的边相对较少时,邻接矩阵表示法太过浪费空间,尤其是用来表示无向图的时候,因为无向图使用邻接矩阵表示法总是对称的,所以浪费空间的情况更严重。
那么有没有什么方式能够减少空间的使用呢?
我们来看一下第二种表示方式——邻接表法,其表示方法如下图所示,n个顶点的图,使用n个链表来标示,每个链表的头节点标示图中的其中一个顶点,头节点之后的所有后置节点均标示这个顶点可以到达的其他顶点。
如此以来,便大大的节省了存储空间,如果我打算查找A直接到达的顶点,只需要取出头节点为A的链表,然后依次取出其后置节点即可,但是对于有向图,如果需要找到哪些顶点可直达顶点A则需要将所有链表全部遍历,然后过滤出后置节点包含A的链表。
为此,可以引入逆邻接表的方式,链表的后置节点不再保存该顶点可到达的顶点了,而是保存可到达该顶点的顶点。具体使用邻接表还是逆邻接表的方式进行存储,要结合具体的场景来选择。
图的遍历
图的遍历方式有常用的两种方式,深度优先遍历(DFS)和广度优先遍历(BFS)。
深度优先遍历
深度优先遍历的核心就是,选定一个顶点后,不管这个顶点有多少可以直达的其他顶点,先只选择其中的往下走,直到走到无路可走之后,再回退回来选择其他顶点。
如下图所示的快递员的派件过程,就是深度优先遍历的过程
深度优先遍历的代码实现:
/**
* 启动深度优先遍历
* @param graph 图的信息
* @param start 开始的顶点
*/
public static void startDfs(Map<String, List<String>> graph, String start){
//常见一个栈,用于存放当前路径,用于无路可走时的回退
Stack<String> stack = new Stack<>();
stack.push(start);
//创建一个集合,用于存放已走过的顶点
List<String> closed = new LinkedList<>();
//递归遍历
dfs(graph, stack, closed);
}
/**
* 递归深度优先遍历
* @param graph 图信息
* @param stack 当前路径,用于回退
* @param closed 已经访问过的顶点
*/
public static void dfs(Map<String, List<String>> graph, Stack<String> stack, List<String> closed){
//递归出口,当前已无退路,标示已经回退到了起始顶点,遍历结束
if(stack.empty()){
return;
}
//窥探当前栈顶元素,即当前所处的顶点
String point = stack.peek();
//输出当前顶点
System.out.print(point);
//将当前顶点添加到已访问过的集合里
closed.add(point);
//获取当前顶点可到达的顶点
List<String> directAccessPoints = graph.get(point);
if(directAccessPoints != null){
directAccessPoints.stream()
//过滤掉已经访问过的顶点
.filter(item -> !closed.contains(item))
//遍历剩余的顶点,对剩余的顶点进行深度优先遍历
.forEach(item -> {
stack.push(item);
dfs(graph, stack, closed);
});
}
//当前顶点已无路可去,弹栈回退
stack.pop();
}
广度优先遍历
广度优先遍历的核心理念在于,先找到一个顶点,遍历他的所有可直达的顶点,然后对他的所有可直达的顶点再做广度优先遍历。
广度优先遍历代码实现:
/**
* 启动广度优先遍历
* @param graph 图的信息
* @param start 开始的顶点
*/
public static void startBfs(Map<String, List<String>> graph, String start){
//创建一个集合,用于存放已走过的顶点
List<String> closed = new LinkedList<>();
//输出当前顶点
System.out.print(start);
//将当前节点增加到已访问列表
closed.add(start);
//递归遍历
bfs(graph, start, closed);
}
/**
* 递归广度优先遍历
* @param graph 图信息
* @param point 当前顶点
* @param closed 已经访问过的顶点
*/
public static void bfs(Map<String, List<String>> graph, String point, List<String> closed){
//获取当前顶点可到达的顶点
List<String> directAccessPoints = graph.get(point);
if(directAccessPoints != null){
//过滤掉已经访问过的顶点
List<String> filter = directAccessPoints.stream()
.filter(item -> !closed.contains(item)).collect(Collectors.toList());
//输出未访问过的直连顶点,并将其添加到访问列表
filter.forEach(item -> {
System.out.print(item);
closed.add(item);
});
filter.forEach(item -> {
bfs(graph, item, closed);
});
}
}
拓展
- 加权图
- 最小生成树、最大生成树
关于图的生成树的问题,后续会在解算法题遇到的时候再做讲解,本篇暂不涉及最大最小生成树
PS: 数据结构基础到此先告一段落,接下来将开启算法篇,通过实战算法来巩固所学的知识,也会通过算法对这些数据结构做一个更深层次的理解,但是在开始之前,忘忧期望大家能够把之前所讲的基础知识掌握牢固,这是后续解题的关键所在。
第一个青春是上帝给的,第二个青春是靠自己努力的。