对于ACM图论方面的题目总是免不了首先要建图存图,使用合适的存图方式不但是AC的必要条件,解题事半功倍。
以下主要分析三种常见的存图方式的优缺点以及代码实现
目录
一、邻接矩阵
1.定义1
2.存图思想1
3.表现形式1
4.代码1
5.优缺点1
二、邻接表
1.定义2
2.存图思想2
3.表现形式2
4.代码2
5.优缺点2
三、链式前向星
1.定义3
2.存图思想3
3.表示形式3
4.代码3
5.优缺点3
邻接矩阵是三种存图方式中最简单也最为暴力的一种存图方式了。
使用一个矩阵来描述一个图,对于矩阵的第 i
行第 j
列的值,表示编号为 i
的顶点到编号为 j
的顶点的 权值
无向图 有向图
// 最大顶点数
const int V = 1000;
// 邻接矩阵的定义
// mat[i][j] 表示 顶点`i`到顶点`j`的权值
int mat[V][V];
// 邻接矩阵的初始化操作
// 假设权值为零表示没有该边
memset(mat, 0, sizeof(mat))
// 增加边
// 新增顶点`i`到顶点`j`的边,权值为`w`
mat[i][j] = w;
// 删除边
// 删除顶点`i`到顶点`j`的边
mat[i][j] = 0;
// 查询边
// 查询顶点`i`到顶点`j`的边权
mat[i][j];
使用邻接矩阵来进行建图存图有以下优点
代码易写,简单好操作
对已确定的边进行操作,效率高
确定边(已知两顶点编号)要进行增加或删除边(或者说更改边权)以及查询边权等操作,时间复杂度为O(1)。
易处理重边
你可以随时覆盖掉重边,可以自己实现存储最新的边,权值最大的边或权值最小的边等。
当然,如果你非要使用邻接矩阵存图还要存重边也不是不可以。
缺点:
过高的空间复杂度
对于顶点数V
,邻接矩阵存图的空间复杂度高达 O(V^2) ,顶点数上了一万可以不用考虑这种存图方式了。
对于稀疏图来说,邻接矩阵存图内存浪费太严重,这也是邻接矩阵存图在ACM题目中十分罕见的根本原因。
对于不确定边的查询效率一般
比如,我找个编号为1
出发的第一条边我还要一条条边判断是否存在(权值是否为0
)
邻接表在三种常用的存图方式中属于较为中庸和普遍的存图方式了,缺点不致命,优点不明显。
邻接表则是对于每个顶点使用不定长的链表来存储以该点出发的边的情况。因此对于第 i
个链表的第 j
个值实际上存储的是从编号为 i
的顶点出发的第 j
条边的情况。
一般来说,如果有边权的话,邻接表的链表存储的是一个结构体,这个结构体存储该边的终点以及边权
// 最大顶点数
const int V = 100000;
// vector实现的邻接表的定义
// 不考虑边权,存储类型为int型
vector e[V];
// 邻接表的初始化操作
// 将起点为`i`的边链表全部清空
e[i].clear();
// 增加边
// 新增顶点`i`到顶点`j`的边
e[i].push_back(j);
// 查询边
e[i][0]; // 查询以`i`为起点的第一条边 `i->e[i][0]`
for (int j=0; j<(int)e[i].size(); ++j) {
if (e[i][j] == k) { // 查询边`i->k`
// do something.
}
}
优点
较为简单易学
相比邻接矩阵,无非是数组转链表加上存储值的意义不同而已,不需要转太大的弯。
代码易写,不复杂
代码实现已经演示过了,较简单,不容易写错。
内存利用率较高
对于顶点数V
与边数E
,空间复杂度为 O(V+E) 。能较好处理稀疏图的存储。
对不确定边的操作方便效率也不错
比如,要遍历从某点出发的所有边,不会像邻接矩阵一样可能会遍历到不存在的边。
缺点
重边不好处理
判重比较麻烦,还要遍历已有的边,不能直接判断。
一般情况下使用邻接表存图是会存储重边的,不会做重边的判断。
所以如果要解决重边的影响一般不在存边的情况下做文章。
对确定边的操作效率不高
比如对于给定 i->j
的边要进行查询或修改等操作只有通过遍历这种方式找到了
参考定义
这种存图方式的数据结构主要是边集数组,顾名思义,图的边是用数组来存储的。
我们输入边的顺序为:
1 2
2 3
3 4
1 3
4 1
1 5
4 5
那么排完序后就得到:
编号: 1 2 3 4 5 6 7
起点u: 1 1 1 2 3 4 4
终点v: 2 3 5 3 4 1 5
得到:
head[1] = 1 len[1] = 3
head[2] = 4 len[2] = 1
head[3] = 5 len[3] = 1
head[4] = 6 len[4] = 2
我们建立边结构体为:
struct Edge
{
int next;
int to;
int w;
}edge[1010];
edge[i].to——表示第 i 条边的终点,
edge[i].next ——表示与第 i 条边同起点的下一条边的存储位置,
edge[i].w——边权值.
head[]——用来表示以i为起点的第一条边存储的位置
实际上你会发现这里的第一条边存储的位置其实是在以 i 为起点的所有边的最后输入的那个编号.
head[]数组一般初始化为-1,对于加边的add函数是这样的:
void add(int u,int v,int w)
{
edge[cnt].to = v;
edge[cnt].next = head[u]; // 由于head 是倒存,所以与第cnt条边同起点的下一条边为现在起点的head 值
edge[cnt].w = w;
head[u] = cnt++;
}
head[i]保存的是以i为起点的所有边中编号最大的那个,而把这个当作顶点i的第一条起始边的位置.
这样在遍历时是倒着遍历的,也就是说与输入顺序是相反的,不过这样不影响结果的正确性.
// 最大顶点数
const int V = 100000;
// 最大边数
const int E = 100000;
// 边结构体的定义
struct Edge {
int to; // 表示这条边的另外一个顶点
int next; // 指向下一条边的数组下标,值为-1表示没有下一条边
};
// head[i] 表示顶点`i`的第一条边的数组下标,-1表示顶点`i`没有边
int head[V];
Edge edge[E];
// 链式前向星初始化,只需要初始化顶点数组就可以了
memset(head, -1, sizeof(head));
// 增加边的方式
// 新增边 a -> b,该边的数组下标为`id`
inline void AddEdge(int a, int b, int id)
{
edge[id].to = b;
edge[id].next = head[a]; // 新增的边要成为顶点`a`的第一条边,而不是最后一条边
head[a] = id;
return;
}
// 遍历从`a`点出去的所有边
for (int i=head[a]; i!=-1; i=e[i].next) {
// e[i] 就是你当前遍历的边 a -> e[i].to
}
优点
内存利用率高
相比vector
实现的邻接表而言,可以准确开辟最多边数的内存,不像vector
实现的邻接表有爆内存的风险。
对不确定边的操作方便效率也不错
这点和邻接表一样,不会遍历到不存在的边。
缺点
难于理解,代码较复杂
这种存图方式相对于邻接表来说比较难理解,代码虽然不是很复杂但是不熟练的话写起来也不是方便。
重边不好处理
这点与邻接表一样,只有通过遍历判重。
对确定边的操作效率不高
也与邻接表一样,不能通过两点马上确定边,只能遍历查找。