内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将博客设置为仅粉丝可见。
目录
邻接表
前面我们学习了用邻接矩阵的方式存图,这一节课程我们学习图的另外一种存储方式——邻接表。
邻接表的思想是,对于图中的每一个顶点,用一个数组来记录这个点和哪些点相连。由于相邻的点会动态的添加,所以对于每个点,我们需要用链表来记录。
也就是对于每个点,我们都用一个链表来记录这个点和哪些点相连。比如对于一张有 10 个点的图,10 个链表就可以用来记录这张图了。对于一条从a
到b
的有向边,我们通过链表的插入操作把一条边添加到链表的表头;如果是无向边,则需要同时插入两条方向相反的边。
上图演示了一个图对应的邻接表。每一行的第一列表示的是第几个链表。
邻接表和邻接矩阵对比
用邻接表存图有两个优点。
-
节省空间:当图的顶点数很多、但是边的数量很少时,如果用邻接矩阵,我们就需要开一个很大的二维数组,最后我们需要存储 n^2 个数。但是用邻接表,最后我们存储的数据量只是边数的两倍。
-
可以记录重复边:如果两个点之间有多条边,用邻接矩阵只能记录一条,但是用邻接表就能记录多条。虽然重复的边看起来是多余的,但在很多时候对解题来说是必要的。
当然,有优点就有缺点,用邻接表存图的最大缺点就是随机访问效率低。比如,我们需要询问点 a 是否和点 b 相连,我们就要遍历链表,检查里面是否有 b。而在邻接矩阵中,只需要根据G[a][b]
就能判断。
因此,我们需要对不同的应用情景选择不同的存图方法。如果是稀疏图(顶点很多、边很少),一般用邻接表;如果是稠密图(顶点很少、边很多),一般用邻接矩阵。
当点数较多(多于 5000)时,使用邻接矩阵会超出空间限制,需要使用邻接表。
不带权图邻接表实现参考程序:
// 存储
struct edge {
int v, next;
} e[maxn * 2];
int p[maxn], eid, d[maxn];
void init() {
eid = 0;
memset(p, -1, sizeof(p));
}
void insert(int x, int y) {
e[eid].v = y;
e[eid].next = p[x];
p[x] = eid++;
}
void insert2(int x, int y) {
insert(x, y);
insert(y, x);
}
// 遍历所有边
for (int i = 1; i <= n; i++) {
for (int j = p[i]; ~j; j = e[j].next) { // ~j 相当于 j != -1
cout << i << "->" << e[j].v << endl;
}
}
邻接表的演示
略
带权图的邻接表存储
带权图邻接表实现参考程序:
// 存储
struct edge {
int v, d, next;
} e[maxn * 2];
int p[maxn], eid, d[maxn];
void init() {
eid = 0;
memset(p, -1, sizeof(p));
}
void insert(int x, int y, int d) {
e[eid].v = y;
e[eid].d = d;
e[eid].next = p[x];
p[x] = eid++;
}
void insert2(int x, int y, int d) {
insert(x, y, d);
insert(y, x, d);
}
// 遍历所有边
for (int i = 1; i <= n; i++) {
for (int j = p[i]; ~j; j = e[j].next) { // ~j 相当于 j != -1
cout << i << "->" << e[j].v << ": " << e[j].d << endl;
}
}
树的概念
在介绍树之前,我们先介绍两个图的概念。
路径
在无向图 G 中,如果从顶点 v_i 出发,沿着图中的边经过一些顶点 v_p_1,v_p_2,…,v_p_m 到达顶点 v_j,则称顶点序列 (v_i,v_p_1,…,v_p_m,v_j) 为从顶点 v_i 到顶点 v_j 的一条 路径(Path),其中 (v_i,v_p_1),(v_p_1,v_p_2),…,(v_p_m,v_j) 均为 G 中的边。如果 G 是有向图,则 (<v_i,v_p_1>,<v_p_1,v_p_2>,…,<v_p_m,v_j>) 均为 G 中的有向边。
路径中边的数量被称为 路径长度。如果路径中的顶点均不重复,则称这条路径为 简单路径。如果路径中的第一个顶点 v_i 和最后一个顶点 v_j 是同一个顶点,则称这条路径为 回路。
连通性
在 无向图 中,如果从顶点 u 到顶点 v 有路径,则称点 u 和点 v 是 连通 的。如果无向图中任意一对顶点之间都是连通的,那么这个无向图就是 连通图。
下面我们利用第一个例子来介绍几个和树相关的概念。
树是由若干个有限结点组成的一个具有层次关系的集合,每棵树有且仅有一个根,比如在图中,最上面的结点就是树的根结点。例子里的/
、etc
、usr
、lib
等等都是这棵树上的结点,其中/
是树的根结点。
图中某个结点及其下面的所有结点以及结点之间的边,被称为以该结点为根的子树,例如usr
、lib
、bin
就是/
的一棵子树,usr
是该子树的根。结点拥有的子树个数我们称为结点的度,比如结点/
的度为 7,home
的度为 3。在例子中,我们称usr
是lib
、bin
的父亲,lib
、bin
是usr
的孩子。没有孩子的结点,也就是度为 0 的结点我们称为叶子,例如etc
、lib
、bin
都是叶子结点。
我们规定根结点是树的第一层,树根的孩子结点是树的第二层,以此类推,树的深度就是结点的最大层数,例如例子里的树,它的深度为 4。
现在你学会了一些和树相关的概念。下面我们用第二个例子简单复习下这几个概念,从图上,我们可以看到这是一棵以美国福特汽车公司
为根结点,深度为 3 的树,马自达
、俊郎
是以美国福特汽车公司
为根结点的一棵子树。美国福特汽车公司
度为 8,路虎
度为 0。美国福特汽车公司
是阿斯顿马丁
、路虎
、捷豹
等的父亲,阿斯顿马丁
、路虎
、捷豹
是美国福特汽车公司
的孩子,路虎
、野马
、雷鸟
等都是树的叶子结点。
现在,我们用图的概念来定义树:如果一个无向 连通 图中不存在回路(环),则称这个图为 树。
也就是说,树本质上是一种特殊的图。我们可以指定图中的一个顶点为树的根,此时这棵树就被称作 有根树,而在没有根的状态下,这棵树被称作 无根树。
我们可以发现一个包含 n 个结点的有根树的一些性质:
-
每棵非空有根树有且仅有一个根结点。
-
父结点可以有多个孩子结点,除根结点外,其余的结点有且仅有一个父结点。
-
根结点没有父结点,叶结点没有孩子结点。
-
若树上的结点数为 n,则边数一定为 n−1。
-
树上的任意一对结点之间 有且仅有 一条路径。
由此我们也可以去思考应该如何根据给定的信息建立一棵树。第一种场景下,题目给定了 n−1 组点之间有边相连,实际上和建立一个普通的图一样。
而第二种场景下,题目指定了节点间的父子关系,例如 x 是 y 的父亲,如果是在无向图上,建边的方式和第一种场景其实相同;而对于有向图而言就需要具体问题具体分析了,大部分时候是父亲向儿子连边。第二种场景和第一种场景的核心区别是,在第二种场景下,树的根是 确定 的;而在第一种场景下,很多时候我们还需要进行 额外的操作 去假设或者确定树的根。
树的性质还有很多,这里我们就不一一介绍了。树是一种特殊又重要的数据结构,在今后的算法和数据结构的学习中,你会经常与树结构打交道。
树的更多性质
在介绍完树的基础概念之后,我们来介绍树上的一些概念和性质。
边权
树是一种特殊的图,一棵树同时也是一个图。我们之前学习图的时候都知道有一种图叫做带权图。那么树作为图的一种,每条边自然也能被赋予权值。
叶子结点
树的叶子结点指的是没有任何孩子的结点,它可以有父亲,但是一定没有孩子。那么我们怎么判断叶子结点呢?很简单,既然它只有父亲没有孩子,那么和它相邻的点就一定只有父亲结点,因此,在树的点数大于 1 的情况下 叶子结点在图上的度数必然为 1。利用这个性质可以很好的在树当中寻找出叶子结点。
需要注意区分树上的度和图上的度数的区别:树上的度指的是一个结点所拥有的子树数目,而图上的度数指的是一个结点所连接的边的条数。叶子结点在图上的度数必然为 1,而叶子结点不会有任何子树,所以在树上的度是 0。
距离
树上的每两个点之间有且只有一条简单路径,因此,我们定义树上的两个结点的距离为这两个点路径上的所有边的边权之和(在不带权图中就是经过的边的数量)。