图和树基础

34 篇文章 1 订阅
34 篇文章 2 订阅

2.1. 什么是图
在一个社交网络中,每个帐号和他们之间的关系构成了一张巨大的网络,就像下面这张图:
在这里插入图片描述
那么在电脑中,我们要用什么样的数据结构来保存这个网络呢?这个网络需要用一个之前课程里未提到
过的数据结构,也就是接下来要讲解的 图 结构来保存。
到底什么是图?图是由一系列顶点和若干连结顶点集合内两个顶点的边组成的数据结构。数学意义上的
图,指的是由一系列点与边构成的集合,这里我们只考虑有限集。通常我们用 表示一个
图结构,其中 表示点集, 表示边集。
在顶点集合所包含的若干个顶点之间,可能存在着某种两两关系——如果某两个点之间的确存在这样的
关系的话,我们就在这两个点之间连边,这样就得到了边集的一个成员,也就是一条边。对应到社交网
络中,顶点就是网络中的用户,边就是用户之间的好友关系。
如果用边来表示好友关系的话,对于微信这种双向关注的社交网络没有问题,但是对于微博这种单向关
注的要如何表示呢?
于是引出了两个新的概念:有向边和无向边。
简而言之,一条有向边必然是从一个点指向另一个点,而相反方向的边在有向图中则不一定存在;而有
的时候我们并不在意构成一条边的两个顶点具体谁先谁后,这样得到的一条边就是无向边。就像在微信
中, 是 的好友,那 也一定是 的好友,而在微博中, 关注 并不意味着 也一定关注 。
对于图而言,如果图中所有边都是无向边,则称为无向图,反之称为有向图。
简而言之,无向图中的边是“好友”,而有向图中的边是“关注”。一般而言,我们在数据结构中所讨论的
图都是有向图,因为有向图相比无向图更具有代表性。
G = (V , E)
V E
A B B A A B B A
实际上,无向图可以由有向图来表示。如果 两个点之间存在无向边的话,那用有向图也可以表示
为 两点之间同时存在 到 与 到 两条有向边。
仍然以社交网络举例:虽然微博中并不存在明确定义的好友关系,但是一般情况下,如果你和另一个
ID 互相关注的话,那么我们也可以近似认为,你和 TA 是好友。
我们来形式化地定义一下图:图是由顶点集合(简称 点集)和顶点间的边(简称 边集)组成的数据结
构,通常用 来表示。其中点集用 来表示,边集用 来表示。在无向图中,边连接的两个顶点是无序的,这些边被称为 无向边。例如下面这个无向图 ,其点集为 1 2 5 3 6,边集为 {(1, 2),(2, 3),(1, 5),(2, 6),(5, 6)}
在这里插入图片描述
而在有向图中,边连接的两个顶点之间是有序的。箭头的方向就表示有向边的方向。
例如下面这张有向图 :
在这里插入图片描述
其点集 为{1, 2, 3, 5, 6},边集为 {(1, 2),(2, 3),(2, 6),(6, 5),(1, 5)}。对于每 条边 ,我们称其为从 到 的一条有向边, 是这条有向边的 起点, 是这条有向边的 终点。注意在有向图中, 和是不同的两条有向边。

2.2. 图的常用概念
这一节课我们来学习图的几个常用概念。
2.2.1. 图的分类
有很少边或弧(如 , 指边数, 指顶点数)的图称为 稀疏图,反之称为 稠密图。对应
到微博里,如果在一个圈内,同学们都互相关注,则我们可以认为该关系图是一个稠密图,如果只有几
个人关注了别人,则我们可以认为这是一个稀疏图。如果图中边集为空,则称该图为 零图。
如果无向图中任何一对顶点之间都有一条边相连,也就是有 不重复的边,则这个无向
图被称为 完全图。类似地,如果有向图中任何一对顶点 之间都有两条有向边 相连,则称这个有向图为 有向完全图。下图就是由 个顶点组成的无向完全图。
在这里插入图片描述
2.2.2. 度
在无向图中,顶点的 度 是指某个顶点连出的边数。例如在下图中,顶点 的度数为 ,顶点 的度数
为 2
在这里插入图片描述
在有向图中,和度对应的是 入度 和 出度 这两个概念。顶点的入度是指以该顶点为终点的有向边数
量;顶点的出度是指以顶点为起点的有向边数量。需要注意的是,在有向图里,顶点是没有 度 的概念
的。例如在下图中,顶点 的入度为 ,出度为 ;顶点 的入度为 2,出度为2 。
4在这里插入图片描述

2.2.3. 度的性质
在无向图或有向图中,顶点的度数总和为边数的两倍,即:
而在有向图中,有一个很明显的性质就是,入度等于出度。
在无向图中,度序列 的定义为:将图 中所有顶点的度数排成一个序列 ,则称 为图 的度序
列。例如下面这张图:
在这里插入图片描述
其对应的一个度序列为 ,而 也是其对应的度序列。换句话说,每个无向图对
应的度序列不一定是唯一的。

2.2.4. 可图
给定一个非负整数序列,若存在一个无向图使得图中各点的度与此序列一一对应,则称此
序列可图化。判断一个序列是不是可图的(排除重边和自环的情况),可以借助 HavelHakimi
定理:由
非负整数组成的不递增序列 是可图的,当且仅当序列
是可图的。也就是说,序列也是由非负整数
组成的有限非递增序列, 是由删除第一个元素之后的前个元素分别减1后得到的序列。
例如,我们要验证 是否是可图的,也就相当于判断 是否是可图的,也就相当于
判断 是否是可图的。而 显然是可图的(对应一个零图)。因此,序列 就是
可图的。
而对于序列 ,经过如下的计算过程:
3 3 1
2 0
‐1
出现了负数,因此这个序列一定是不可图的。

【小练习】度的概念
通过上面的学习,相信你对度、入度、出度这几个概念有了一定的了解了,选出下面 正确 的选项。
一般在有向图里,我们会提及入度和出度这两个概念。
在无向图里,图中的边数等于所有顶点度数和的一半。
一般在无向图里,我们会提及入度和出度这两个概念。
在无向图里,图中的边数等于所有顶点度数和。

2.3. 邻接矩阵
这一节我们来学习的图的一种储存方式——邻接矩阵。
什么是邻接矩阵呢?所谓邻接矩阵存储结构就每个顶点用一个一维数组存储边的信息,这样所有点合起
来就是用矩阵表示图中各顶点之间的邻接关系。所谓矩阵其实就是二维数组。
对于有 个顶点的图 来说,我们可以用一个 的矩阵 来表示 中各顶点的相
邻关系,如果 和 之间存在边(或弧),则 ,否则 。下图为有向图
和无向图 对应的邻接矩阵:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

一个图的邻接矩阵是唯一的,矩阵的大小只与顶点个数 有关,是一个 的矩阵。前面我们已
经介绍过,在无向图里,如果顶点 和 之间有边,则可认为顶点 到 有边,顶点 到 也
有边。对应到邻接矩阵里,则有 。因此我们可以发现,无向图的邻接矩阵是一
个对称矩阵。
在邻接矩阵上,我们可以直观地看出两个顶点之间是否有边(或弧),并且很容易求出每个顶点的度,
入度和出度。
这里我们以 为例,演示下如何利用邻接矩阵计算顶点的入度和出度。顶点的出度,即为邻接矩阵
上点对应行上所有值的总和,比如顶点 1出度即为 0 + 1 + 1 + 1 = 3;而每个点的入度即为点对应
列上所有值的总和,比如顶点3 对应的入度即为1 + 0 + 0 + 1 = 2 。
接下来我们就先一起学习构造和使用邻接矩阵的方法。邻接矩阵是一个由 和 构成的矩阵。处于第
行、第 列上的元素 和 分别代表顶点 到 之间存在或不存在一条又向边。
显然在构造邻接矩阵的时候,我们需要实现一个整型的二维数组。由于当前的图还是空的,因此我们还
要把这个二维数组中的每个元素都初始化为 。
在构造好了一个图的结构后,我们需要把图中各边的情况对应在邻接矩阵上。实际上,这一步的实现非
常简单,当从顶点 到 上存在边时,我们只要把二维数组对应的位置置为 就好了。
用邻接矩阵来构建图需要如下几步,我们可以用二维数组 G 来表示一个图。

2.3.1. 初始化
初始化的过程很简单,只需要把数组初始化为 即可。可以借助 memset 来快速地将一个数组中的所有
元素都初始化为 。

	memset(G, 0, sizeof(G));

注意, memset 只能用来初始化 和 ,并且需要加上头文件 cstring 。
上面的代码等价于:

    for (int i = 0; i <= N; i++) { // N 为图中结点总数
    for (int j = 0; j <= N; j++) {
    G[i][j] = 0;
    }
    }

2.3.2. 插入边
如果插入一条无向边 ,只需要 G[u][v] = G[v][u] = 1 。
如果插入一条有向边 ,只需要 G[u][v] = 1 。
2.3.3. 访问边
如果 G[u][v] = 1 ,说明有一条从 到 的边,否则没有从 到 的边。
【小练习】写出邻接矩阵
(1)在这里插入图片描述
(2)在这里插入图片描述
(3)在这里插入图片描述
【实践操作】邻接矩阵的实现

这一节课,我们学习用图来记录班上的同学之间的朋友关系。
众所周知,朋友之间关系并不是双向的,比如小明把小红当成朋友,但是小红不一定把小明当成朋友,
可能小红比较傲娇。所以我们需要用有向图来记录朋友之间的关系。班上一共有 名同学,同学们的
编号依次为 到 。
我们用一个二维数组 G[6][6] 来记录这个有向图。如果 ,表示 把 当成他的朋友,也
就是从 连向 一条有向边。
注意,数组大小要开到 (因为编号从 开始),数组中的元素初始化成为 。
在 main 函数第一行写下:

   int G[6][6];
    memset(G, 0, sizeof(G));

并且在代码头部 #include 之后引入头文件 cstring 。
接下来,我们输入 对伙伴关系,每一对关系表示 把 当成朋友。
在 main 函数里面继续写入:

    int m;
    cin >> m;
    for (int i = 0; i < m; i++) {
    int a, b;
    cin >> a >> b;
    G[a][b] = 1;
    }

如果 a把 b当朋友,但是 b不把 a当成朋友,那么a 和b 之间就不是真正的朋友。只有当 a、b 相互
都把对方当朋友的时候,他们才是真正的朋友。现在我们要计算一下,每个人有多少个真正的朋友?
我们循环枚举每个人,在刚刚写入的代码后面写下:

    for (int i = 1; i <= 5; i++) {
    }

接下来,我们开始统计每个人有多少真正的朋友吧。
对于第 个人,首先我们需要用一个数 sum 来记录真正的朋友的个数,然后依次枚举每一个人为 ,只
有当 G[i][j] = 1 并且 G[j][i] = 1 的时候,他们才是真正的朋友。
在循环里面继续写到

int sum = 0;
for (int j = 1; j <= 5; j++) {
if (G[i][j] == 1 && G[j][i] == 1) {
sum++;
}
}
cout << i << " 有 " << sum << " 个真正的朋友。" << endl;

这一节已经完成了,点击 运行,输入下面的数据,看一看效果吧:
10
1 2
2 1
1 4
1 5
5 1
4 5
5 4
3 4
2 4
4 1

2.4. 邻接表
前面我们学习了用邻接矩阵的方式存图,这一节课程我们学习图的另外一种存储方式——邻接表。
邻接表的思想是,对于图中的每一个顶点,用一个数组来记录这个点和哪些点相连。由于相邻的点会动
态的添加,所以对于每个点,我们需要用 vector 来记录。
也就是对于每个点,我们都用一个 vector 来记录这个点和哪些点相连。比如对于一张有 个点的
图, vector G[11] 就可以用来记录这张图了。对于一条从 a 到 b 的有向边,我们通
过 G[a].push_back(b) 就可以把这条边添加进去;如果是无向边,则需要在 G[a].push_back(b) 的同
时 G[b].push_back(a) 。
在这里插入图片描述在这里插入图片描述

上图显示了一个图对应的邻接表。每一行的第一列表示的是最外层 vector 数组的下标。

2.4.1. 邻接表和邻接矩阵对比
用邻接表存图有两个优点。
1. 节省空间:当图的顶点数很多、但是边的数量很少时,如果用邻接矩阵,我们就需要开一个很大的
二维数组,最后我们需要存储 个数。但是用邻接表,最后我们存储的数据量只是边数的两倍。
2. 可以记录重复边:如果两个点之间有多条边,用邻接矩阵只能记录一条,但是用邻接表就能记录多
条。虽然重复的边看起来是多余的,但在很多时候对解题来说是必要的。
当然,有优点就有缺点,用邻接表存图的最大缺点就是随机访问效率低。比如,我们需要询问点 是
否和点 相连,我们就要遍历 G[a] ,检查这个 vector 里是否有 。而在邻接矩阵中,只需要根
据 G[a][b] 就能判断。
因此,我们需要对不同的应用情景选择不同的存图方法。如果是稀疏图(顶点很多、边很少),一般用
邻接表;如果是稠密图(顶点很少、边很多),一般用邻接矩阵。

【实践操作】邻接表的实现
在这一节,我们来学习用邻接表存储图。
首先,我们定义一个用来存 个点的邻接表 G[11] 。 G[i] 用来储存 i 能到达的点。
在 main 函数第一行写下:
vector G[11];
并在开头 #include 之后加上头文件 。
接下来,我们输入 条无向边,并且将它们添加到邻接表。我们可以把无向图看作特殊的有向图,每
一条无向边 对应两条有向边 和 。
在刚才的 G[11] 的定义后面继续写上:

    int m;
    cin >> m;
    for (int i = 0; i < m; i++) {
    int a, b;
    cin >> a >> b;
    G[a].push_back(b);
    G[b].push_back(a);
    }

最后,我们输出和每个点相连的顶点。首先,用一层循环来遍历每个点(点的编号从 开始)。
继续在刚才的代码后写下:

    for (int i = 1; i <= 10; i++) {
    cout << i << " : ";
    }

最后,我们对于每个点,遍历它对应的邻接表,并输出其中相邻的顶点。
在内层循环写下:

    for (int j = 0; j < G[i].size(); j++) {
    cout << G[i][j] << " ";
    }
    cout << endl;

这一节已经完成了,点击 运行,输入下面的数据,看一看程序运行的结果吧。

10
1 2
2 4
3 4
5 6
7 9
10 1
8 9
9 5
3 7
3 8

2.5. 带权值的图
在前面的课程中,图中的边都只是用来表示两个点之间是否存在关系,而没有体现出两个点之间关系的
强弱。比如在社交网络中,不能单纯地用 、 来表示两个人否为朋友。当两个人是朋友时,有可能是
很好的朋友,也有可能是一般的朋友,还有可能是不熟悉的朋友。
我们用一个数值来表示两个人之间的朋友关系强弱,两个人的朋友关系越强,对应的值就越大。而这个
值就是两个人在图中对应的边的权值,简称 边权。对应的图我们称之为 带权图。
如下就是一个带权图,我们把每条边对应的边权标记在边上:
在这里插入图片描述
2.5.1. 带权图的储存
带权图也分成带权有向图和带权无向图。前面学到的关于图的性质在带权图上同样成立。实际上,我们
前面学习的图是一种特殊带权图,只不过图中所有边的权值只有 一种;而在带权图中,边的权值可
以是任意的。
2.5.1.1. 邻接矩阵储存
用邻接矩阵存储带权图和之前的方法一样,用 G[a][b] 来表示 和 之间的边权(我们需要用一个数
值来表示边不存在,如 0 )。同样,对于无向图,这个矩阵依然是对称的。
在这里插入图片描述
0 3 5 0
3 0 4 1
5 4 0 0
0 1 0 0
如上所示,上边的图对应的下边的邻接矩阵
2.5.1.2. 邻接表储存
用邻接表存储带权图和之前的实现方式略有区别,我们需要用一个结构体来记录一条边连接的点和这条
边的边权,然后用一个 vector 来存储若干个结构体。
结构体的定义举例如下:

struct node {
int v; // 用来记录连接的点
int len; // 用来记录这条边的边权
};

我们通常把向图中加入一条边写成一个函数,例如加入一条 有向边 、边权为 ,就可以用如下
的函数来实现(我们需要把图定义成全局变量)。

    vector<node> G[110];
    // 插入有向边
    void insert1(int u, int v, int w) {
    node temp;
    temp.v = v;
    temp.len = w;
    G[u].push_back(temp);
    }
而插入一条无向边,实际上相当于插入两条方向相反的有向边:
// 插入无向边
void insert2(int u, int v, int w) {
insert1(u, v, w);
insert1(v, u, w);
}

【实践操作】带权图邻接表的实现
这一节我们学习用邻接表来存储带权图。
首先我们定义好用来存图的结构体,结构体里面有两个变量,分别记录连接的点和边权。
同时,定义好储存图的 vector 。
在 main 函数上面写下:
(u, v) w
0ofz.html 14/19
struct node {
int v, w;
};
vector G[11];
接下来,我们用函数来完成边的插入。首先,我们实现一个函数 insert1 来完成一条有向边的插入。这
个函数有三个参数 ,表示一条从 到 的边权为 的边。
在函数里面,我们需要定义一个结构体,把传进来的参数赋值给结构体,然后添加到邻接表里面。
在 main 函数上面写下:

void insert1(int u, int v, int w) {
node temp;
temp.v = v;
temp.w = w;
G[u].push_back(temp);
}

我们继续定义一个函数 insert2 来插入一条无向边,等价于插入两条有向边。
继续在 main 函数上面写下:

void insert2(int u, int v, int w) {
insert1(u, v, w);
insert1(v, u, w);
}

接下来,我们定义一个 input 函数,来完成读入 条 无向边 的工作。
继续在 main 函数上面写下:

void input() {
int m;
cin >> m;
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
insert2(u, v, w);
}
}

之后,我们把刚才读入的图输出出来。还是写一个函数 output 来完成这个工作。
在 main 函数上面继续写下:
u, v, w u v w
m

void output() {
for (int i = 1; i <= 10; i++) {
for (int j = 0; j < G[i].size(); j++) {
cout << "(" << i << ", " << G[i][j].v << ", " << G[i][j].w << ")" << endl;
}
}
}

最后,我们只需要在 main 函数里面依次调用 input 和 output 就好了。
这一节已经完成了,点击 运行,输入下面的数据,看一看运行结果吧。
10
1 2 3
2 4 4
3 4 2
5 6 1
7 9 0
10 1 ‐7
8 9 ‐4
9 5 10
3 7 11
3 8 20

2.6. 树的概念
在介绍树之前,我们先介绍两个图的概念。
2.6.1. 路径
在无向图 中,如果从顶点 出发,沿着图中的边经过一些顶点 到达顶点 ,
则称顶点序列 为从顶点 到顶点 的一条 路径(Path),其中
均为 中的边。如果 是有向图,则
均为 中的有向边。
路径中边的数量被称为 路径长度。如果路径中的顶点均不重复,则称这条路径为 简单路径。如果路径
中的第一个顶点 和最后一个顶点 是同一个顶点,则称这条路径为 回路。
2.6.2. 连通性
在 无向图 中,如果从顶点 到顶点 有路径,则称点 和点 是 连通 的。如果无向图中任意一对
顶点之间都是连通的,那么这个无向图就是 连通图。
下面我们利用第一个例子来介绍几个和树相关的概念。
树是由若干个有限结点组成的一个具有层次关系的集合,每棵树有且仅有一个根,比如在图中,最上面
的结点就是树的根结点。例子里的 / 、 etc 、 usr 、 lib 等等都是这棵树上的结点,其中 / 是树的根 结点。
在这里插入图片描述

图中某个结点及其下面的所有结点以及结点之间的边,被称为以该结点为根的子树,例
如 usr 、 lib 、 bin 就是 / 的一棵子树, usr 是该子树的根。结点拥有的子树个数我们称为结点的
度,比如结点 / 的度为 , home 的度为 。在例子中,我们称 usr 是 lib 、 bin 的父
亲, lib 、 bin 是 usr 的孩子。没有孩子的结点,也就是度为 的结点我们称为叶子,例
如 etc 、 lib 、 bin 都是叶子结点。
我们规定根结点是树的第一层,树根的孩子结点是树的第二层,以此类推,树的深度就是结点的最大层
数,例如例子里的树,它的深度为4 。
在这里插入图片描述
现在你学会了一些和树相关的概念。下面我们用第二个例子简单复习下这几个概念,从图上,我们可以
看到这是一棵以 美国福特汽车公司 为根结点,深度为 3的树, 马自达 、 俊郎 是以 美国福特汽车公司 为根 结点的一棵子树。 美国福特汽车公司 度为 8, 路虎 度为0。 美国福特汽车公司 是 阿斯顿马丁 、 路虎 、 捷豹 等的父亲, 阿斯顿马丁 、 路虎 、 捷豹 是 美国福特汽车公司 的孩子, 路虎 、 野马 、 雷鸟 等都是树的叶子结点。
在这里插入图片描述
现在,我们用图的概念来定义树:如果一个无向 连通 图中不存在回路(环),则称这个图为 树。也就
说,树本质上是一种特殊的图。我们可以指定图中的一个顶点为树的根,此时这棵树就被称作 有根
树,而在没有根的状态下,这棵树被称作 无根树。
我们可以发现一个包含 个结点的有根树的一些性质:
1. 每棵非空有根树有且仅有一个根结点。
2. 父结点可以有多个孩子结点,除根结点外,其余的结点有且仅有一个父结点。
3. 根结点没有父结点,叶结点没有孩子结点。
4. 若树上的结点数为 ,则边数一定为 。
5. 树上的任意一对结点之间 有且仅有 一条路径。
树的性质还有很多,这里我们就不一一介绍了。树是一种特殊又重要的数据结构,在今后的算法和数据
结构的学习中,你会经常与树结构打交道。

【例题1】关系查询
输入 n对朋友关系,朋友关系是相互的。 b是 a的朋友,a 也是b 的朋友。
然后有 m次查询,每次查询询问 a和 b是否是朋友。
输入格式
第一行输入一个整数 。
接下来 行,每行输入两个名字,表示一对朋友关系。
接下来一行输入一个整数 ,表示 个查询。
接下来 行,每行输入两个名字,表示一次查询。
输入中的名字只包含大小写字母,长度不超过 。
输出格式
对于每次查询,如果他们是朋友,输出一行 “Yes” ,否则输出一行 “No” 。
样例输入
5
Mary Tom
Islands Barty
Andy Amy
Islands Amy
Tom Mary
3
Amy Andy
Islands Tom
Islands Barty
样例输出
Yes
No
Yes
提示
借助map,把名字映射成整数,然后用邻接矩阵储存即可。
【例题2】旅行
小信所在的城市可以抽象成为一个有 个点和 条无向边的
地图。
m a b
n(1 ≤ n ≤ 100)
n
m(1 ≤ m ≤ 100) m
m
20
n(1 ≤ n ≤ 20000) m(1 ≤ m ≤ 50000)
小信住在 号点,他想进行一次环城市旅游。他从 点出发,每次沿着和 点相连的边中最短的边到
下一个城市(如果有很多个最短的边,选择编号最小的走),到达下一个城市以后,还是沿着和这个城
市相连的最短边走到下一个点(如果有很多个最短的边,选择编号最小的走),一直这样走下去,直到
要走到一个已经走过的,就结束这次旅行。
输入格式
输入第一行两个整数 , 。
接下来 行,每行输入三个整数 , ,表示有一条连接
和 长度为 的无向边。
输出格式
输出一行若干个整数,依次表示小信旅行经过的点,每两个数中间用一个空格隔开。
样例输入
4 4
3 4 4
4 2 5
2 1 7
4 1 5
样例输出
1 4 3
1 1 1
n m
m u, v(1 ≤ u, v ≤ n, u ≠ v) w(1 ≤ w ≤ 106)
u v w

标准结尾:
在这里插入图片描述

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值