数据结构与算法 | 图(Graph)
在这之前已经写了数组、链表、二叉树、栈、队列等数据结构,本篇一起探究一个新的数据结构:图(Graphs )。在二叉树里面有着节点(node)的概念,每个节点里面包含左、右两个子节点指针;比对于图来说同样有着节点(node),在图里也称为顶点(vertex)
,顶点之间的关联不在局限于2个(左、右),一个顶点可以与任意(0-n个)个顶点进行链接,这称之为边(edge)
。 一般会把一个图里面顶点的集合记作 V
,图里面边的集合记作 E
,图也就用 G(V,E)
来表示。
对比二叉树可以看到图的约束更少,换一个角度看二叉树结构是图的特殊形式,所谓特殊形式指加上更多的限定条件。
图的分类(Types Of Graph)
可以看到图的基本的结构非常简单,约束也很少,如果在其中加上各种条件约束就可以定义各种类型的图。
- 约束边或者顶点个数来分类:
零图(Null graph)
:只有顶点没有边的图;平凡图(Trivial graph)
:只有一个顶点的图;
- 按照边是否有指向来分类:
有向图(Directed Graph)
:在每个边的定义中,节点都是有序的对。也就是(A,B)与(B,A)表示不同的边,一个代表从A到B方向的边,一个代表从B到A方向的边。无向图(Undirected Graph)
:边只是代表链接,没有指向性。(A,B)与(B,A)表示的同样的边。
- 根据是否在边上存储数据分类:
权重图(Weighted Graph)
:图中的边上附加了权重或值的图。这些权重表示连接两个节点之间的距离、代价、容量或其他度量。权重可以是任何数值,通常用于描述节点间的关系特性。
还有很多分类在此不一一罗列。每类图可能还会有其独特的一些特征描述,比如有向图(Directed Graph)
里面,以某顶点作为开始的边的数量称为这个顶点的入度(Indegree)
,以某个顶点作为结束的边的数量称为这个顶点的出度(Outdegree)
等等。
通过以上描述,可以感受到图其实是非常灵活的数据结构,同时它的衍生概念也非常多;初次探究大可不必一一记牢,有个基本的图结构知识体系即可,后续遇到的时候再扩充图的知识体系更为合适。
图的表达(Representation of Graphs)
图的表达其实也有多种形式,不过最基本的形式是:邻接矩阵(Adjacency Matrix) 与 邻接表(Adjacency List)
邻接矩阵(Adjacency Matrix)
邻接矩阵,所谓“矩阵”具体到代码其实就是二维数组,通过二维数组来表示图中顶点之间的边的关系。二维数组中的行和列分别代表图中的顶点,矩阵中的值表示顶点之间是否相连或连接的边的权重。
且用这种方式来表示先前示例的图结构,矩阵的值 0代表无相连边,1代表有相连边。如下:
邻接表(Adjacency List)
邻接表,所谓“表”指的就是列表 List ,图中的每个节点都有一个对应的列表,用于存储与该节点直接相连的其他节点的信息。邻接表中的每个节点列表包含了该节点相邻节点的标识符或指针等信息。对于无权图,通常使用数组或链表来存储相邻节点的标识符。而对于带权图,列表中可能还包含了边的权重信息。
基本应用示例(Basic Examples)
Leetcode 997. 找到小镇的法官【简单】
小镇里有 n 个人,按从 1 到 n 的顺序编号。传言称,这些人中有一个暗地里是小镇法官。
如果小镇法官真的存在,那么:
小镇法官不会信任任何人。
每个人(除了小镇法官)都信任这位小镇法官。
只有一个人同时满足属性 1 和属性 2 。
给你一个数组 trust ,其中 trusti = ai, bi 表示编号为 ai 的人信任编号为 bi 的人。
如果小镇法官存在并且可以确定他的身份,请返回该法官的编号;否则,返回 -1 。
示例
输入:n = 2, trust = [1,2]
输出:2
题目故事背景描述比较多,可以看到 信任的表述 可以用有向图
的边
来表示,每个人 用顶点
来表示,小镇法官的第1点 代表就是出度
为 0
,第2点 代表就是 入度
为 n-1
。 这样题目就转换为:判断一个n个顶点的有向图中 是否存在出度为0,入度为n-1的顶点 ;存在返回顶点编号,不存在返回 -1。
PS:关键点,将复杂描述的题目,建模成为图
public int findJudge(int n, int[][] trust) {
int[] outDegree = new int[n+1],inDegree = new int[n+1];
for(int i = 0; i < trust.length; i++){
outDegree[trust[i][0]] ++;
inDegree[trust[i][1]]++;
}
for(int i=1; i<= n; i++)
if(outDegree[i] == 0 && inDegree[i] == (n-1))
return i;
return -1;
}
Leetcode 787. K 站中转内最便宜的航班【中等】
有 n 个城市通过一些航班连接。给你一个数组 flights ,其中 flightsi = fromi, toi, pricei ,表示该航班都从城市 fromi 开始,以价格 pricei 抵达 toi。
现在给定所有的城市和航班,以及出发城市 src 和目的地 dst,你的任务是找到出一条最多经过 k 站中转的路线,使得从 src 到 dst 的 价格最便宜 ,并返回该价格。 如果不存在这样的路线,则输出 -1。
示例
输入: n = 3, edges = [0,1,100,1,2,100,0,2,500],src = 0, dst = 2, k = 1
输出: 200
备注:1 <= n <= 100,航班没有重复,且不存在自环
将城市看作是顶点,城市-城市之间的航班看作是 有向图边,航班的价格作为边的权重,也就完成了题意到图的建模。考虑到,城市数量 n < 100, 因此可以采用 邻接矩阵的方式来进行图的表达。
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
// 图 初始化建模
int[][] map = new int[n][n];
for(int i = 0; i < flights.length; i++){
map[flights[i][0]][flights[i][1]] = flights[i][2];
}
// 其他逻辑
}
以 src 作为 源顶点,通过以 src作为 起始顶点的边 链接到更多的顶点(此时经过 0个站中转);以这些链接到的顶点 为起始点,继续链接到更多的顶点(经过 1个站中转);继而可以推导到 经过 n 个站中转。这也就是典型的广度优先搜索(BFS),来遍历以src作为 源顶点的图,遍历代码如下:
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
// ...
// BFS
Deque<Integer> que = new ArrayDeque<>();
// src 作为起始点
que.offer(src);
// 经过 k 个中转站
for(int i = 0; i <= k && !que.isEmpty(); i++){
int size = que.size();
while( size-- > 0){
int node = que.poll();
for(int j = 0; j < map[node].length; j++){
// map[node][j] == 0 代表 node -> 不相连跳过
if( map[node][j] == 0) continue;
// ... 这里可以加入遍历过程中更多的逻辑
// 进入下一轮遍历
que.offer(j);
}
}
}
// ...
}
考虑题目需要的是 最多经过 k 站中转的 最便宜线路,不妨 广度优先遍历中 用 distSet[]
记录下 src 可到达站点的 最低价格;最后返回 distSet[ dst ]
即可, 这里注意下的是 如果没到达,按照题意应返回 -1
。
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
// ...
int[] distSet = new int[n];
que.offer(src);
for(int i = 0; i <= k && !que.isEmpty(); i++){
// 判断当前最小的 标准 是基于上一轮的遍历结果
int[] pre = Arrays.copyOf(distSet,distSet.length);
int size = que.size();
while( size-- > 0){
int node = que.poll();
for(int j = 0; j < map[node].length; j++){
if( map[node][j] == 0) continue;
// distSet[j] == 0 代表之前没有到达过,因此需要 写入 distSet[j]
// 如果当前距离 不之前大,这个顶点不必进行下一轮遍历
if( distSet[j] != 0 &&
distSet[j] < pre[node] + map[node][j]) continue;
// 记录最小结果
distSet[j] = pre[node] + map[node][j] ;
que.offer(j);
}
}
}
// distSet[j] == 0 代表之前没有到达过,返回 -1
return distSet[dst] == 0 ? -1:distSet[dst];
}
这里其实是 使用 Bellman-Ford 算法
的思想进行解题;在图算法领域还有着很多著名的算法,后续可以整理下更专业的解读,这里只是演示个简单的应用。
Bellman-Ford 算法
,最初由Alfonso Shimbel 1955年提出,但以 Richard Bellman 和 Lester Ford Jr.的名字命名,他们分别于 1958年 和 1956年 发表了该算法,向前辈致敬。
最后附上完整代码:
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
int[][] map = new int[n][n];
for(int i = 0; i < flights.length; i++){
map[flights[i][0]][flights[i][1]] = flights[i][2];
}
int[] distSet = new int[n];
Deque<Integer> que = new ArrayDeque<>();
que.offer(src);
for(int i = 0; i <= k && !que.isEmpty(); i++){
int[] pre = Arrays.copyOf(distSet,distSet.length);
int size = que.size();
while( size-- > 0){
int node = que.poll();
for(int j = 0; j < map[node].length; j++){
if( map[node][j] == 0) continue;
if( distSet[j] != 0 &&
distSet[j] < pre[node] + map[node][j]) continue;
distSet[j] = pre[node] + map[node][j] ;
que.offer(j);
}
}
}
return distSet[dst] == 0 ? -1:distSet[dst];
}
欢迎关注 Java研究者 专栏、博客、公众号等。大伙儿的喜欢是创作最大的动力。