在图论中,最短路是一个基础型重点算法问题,也是在实际工程中存在的经典问题。因此有必要在最短路上花些功夫。
今天先来谈谈存储图的三种常见数据结构:邻接矩阵、邻接表、链式前向星
本篇文章介绍最基础的两种存图数据结构:邻接矩阵 和 邻接表
由于篇幅原因,链式前向星将单独在另一篇博客中进行分析:
URL:【最短路题型总结 II】【存图结构】链式前向星 | CGUZ | N
目录
--------- 简介
--------- 适用情况
--------- 算法搭配
--------- 使用介绍
--------- 代码示例
--------- 简介
--------- 适用情况
--------- 算法搭配
--------- 使用介绍
--------- 代码示例
------- 双向链表实现的邻接表
------- std::list实现邻接表
------- std::vector实现邻接表
一、邻接矩阵
简介:
- 直白、暴力、简单 的存图结构。直接用二维数组 graph[][] 表示图。
- u->v 要有一条长度为 d 的边?好,graph[u][v] = d;
适用情况:
- 存储稠密图(顶点个数 V 一般 < 5000,边的条数 E 和 V² 基本在一个数量级)
- 存在重边 / 删边操作(邻接矩阵可以在 O(1) 内 处理这些操作)
算法搭配:
- 邻接矩阵 + Dijkstra:处理稠密图的单源最短路径问题。当顶点数较小时,未经堆优化的 Dijkstra 也能展现优秀的性能
- 邻接矩阵 + Floyd:处理稠密图的多源最短路径问题。
- 邻接矩阵 + Prim:处理稠密图的最小生成树问题。
基本介绍:
邻接矩阵存储一条边的形式是:如果顶点 u 和顶点 v 之间存在一条有向边 u-->v,且边权为 d,那么等价于 graph[u][v] == d
如果是无权图,那么 d 只要能表示“连通”这个意义、和不连通区分开就行。可以就设 d 为 1.
如果是无向边,那么 u -- v 等价于 graph[u][v] == d && graph[v][u] == d
也就是说对二维数组 graph[][] 进行读写操作就能进行对边的增删查改,非常粗暴、方便 。
那么如何表示 u、v 之间不连通呢?一般我们会用一个特别的数来标记,比如一个很大的数或者-1。但是不要用 0 ,因为理论上一个点到它自己的距离也是 0,这样 graph[u][v] == 0 到底是表示u、v就是同一个点还是表示uv不连通呢?就会产生歧义。
具体操作:
- 添加边:
- 对于有向图,插入 u->v 权重为 d 的边(无权图则 d 可视为1,表示导通): graph[u][v] = d;
- 对于无向图,简单增加一次赋值即可,即:graph[u][v] = graph[v][u] = d;
- 修改边权或者删边:
- 直接修改 graph[u][v] 即可,O(1) 内即可完成
- 遍历单点出发的所有邻接点:
- 需要循环一整行,当图比较稀疏的时候效率较为低下,Θ(V) 内完成
代码示例:
/* 邻接矩阵图:
*
* 顶点编号:0 ~ MAX_V-1
* 示例函数:图的初始化、边的增删改查、简单的遍历
*/
#include <iostream>
#include <cstdio>
#include <cstring>
constexpr int MV(103);
template <typename WeightType, int MAX_V>
class MGraph
{
private:
int V; // 顶点个数 V
bool vis[MAX_V]; // 用于下面的 DFS 演示
WeightType g[MAX_V][MAX_V]; // 存图的主要结构,简单的二维数组
public:
WeightType INF; // 用来标记某条边是不存在的(通常被初始化为无穷或-1)
public:
/* 初始化图:
* 先把顶点数记下来,方便后面的遍历
* 对于边的初始化,通常情况下先把每条边初始化成INF,来表示图不导通
* 然后对角元初始化为 0,表示自己到自己的距离是 0
* 如果边权是 double 类型则不能用 memset,需要自行循环赋值
*/
void init(const int V)
{
this->V = V;
memset(&INF, 0x3f, sizeof(INF));
memset(g, 0x3f, sizeof(g));
for (int v=0; v<V; ++v)
g[v][v] = 0;
}
// 向图中添加一条边,很简单(此函数还可用于删边):
inline void add_edge(const int u, const int v, const WeightType w)
{
g[u][v] = w; // 如果是无向图,则为 g[u][v] = g[v][u] = w;
}
// 查询边权(此函数和add_edge连用 可处理重边):
inline WeightType get_edge(const int u, const int v)
{
return g[u][v];
}
// 查询两点是否互为邻接点(由一条边之间导通):
inline bool connected(const int u, const int v)
{
return g[u][v] != INF;
}
/* 图的应用(这里用简单的 DFS 来体现一下图的遍历):
* 主要是展示一下如何遍历从某点出发的所有邻接点
*/
inline void init_dfs(void)
{
memset(vis, false, sizeof(*vis) * (V));
}
void dfs(const int src)
{
vis[src] = true;
for (int dest=0; dest<V; ++dest) // 从src出发,遍历其所有邻接点
{
if (this->connected(src, dest) && !vis[dest])
{
std::cout << src << "->" << dest << ": " << g[src][dest] << std::endl;
dfs(dest);
}
}
}
};
MGraph<int, MV> graph;
int main()
{
graph.init(6);
graph.add_edge(0, 1, 2);
graph.add_edge(0, 5, 3);
graph.add_edge(1, 2, 5);
graph.add_edge(1, 3, 6);
graph.add_edge(1, 5, 9);
graph.add_edge(2, 3, 6);
graph.add_edge(2, 4, 4);
graph.add_edge(3, 4, 3);
graph.add_edge(3, 5, 6);
graph.add_edge(3, 5, 4); // 覆盖这条边
graph.add_edge(3, 5, graph.INF); // 删掉这条边
graph.init_dfs();
graph.dfs(0);
}
二、邻接表
简介:
- 经济、灵活 的存图结构。利用 链表数组 存图。
- (通常会用 std::vector 来模拟链表)
- 除了 Dijkstra 和 SPFA,在 差分约束系统、网络流、二分图匹配 等其他经典的图论问题中也常常能看见邻接表的身影。
适用情况:
- 存储稀疏图(顶点个数 V 没有很强的限制,边条数 E 的数量级 比 V² 低许多)
- 需要快速地对某个点的所有邻接点 / 发出的所有边 进行遍历
- 尽量无重边、不删边、不查询边权(因为对边的查、改会比较耗时)
算法搭配:
- 邻接表 + Dijkstra:处理稀疏图的单源最短路径问题。对于稀疏图,使用堆优化的 Dijkstra 能显现出更优秀的性能。
- 朴素 Dijkstra 的简单遍历求最近未收录点的复杂度为 O(V) ,更新最短源点距的复杂度为 O(1)
- 使用堆优化的 Dijkstra 求最近未收录点并从堆中删除的复杂度为 O(logV) ,更新最短源点距的复杂度也为 O(logV)
- 因此 对于 V 较大的稀疏图,堆优化的 Dijkstra 的性能将会有明显的提升(这个在后续分析最短路算法的博客中再细说)
- 邻接表 + SPFA:处理稀疏图的单源最短路径问题。还可以应对负权边和检测负环。因此在处理常常出现负权边的差分约束系统问题时也常常会使用邻接表 + SPFA。
- 邻接表 + Kruskal:处理稀疏图的最小生成树问题。
基本介绍:
- 记邻接表的链表数组的名字是 edge
- 设表示边的结构体里面有两个变量 dest 和 weight(dest表示边的终点,weight表示权)
- edge[u]是一个链表,表示从 u 顶点发出的所有的边。edge[u]链表 中有 x 个结点则说明 u 总共发出了 x 条边。每条边的起点都是 u,终点和权重则以结构体形式存在了每一个结点中。
这样邻接表就把整个图都存起来了。比如,如果 edge[u] 这个链表中有3个结点,这3个结点中存放的边的信息分别是 {v1, w1}、{v2, w2}、{v3, w3},那么就说明从 u 这个顶点发出的边有且仅有三条,且它们的起点都是 u,终点分别是 v1、v2、v3,它们的权分别是 w1、w2、w3。每个顶点都有一个链表来表示自己发出的边(链表为空则表示没有边和自己相连),一共 V 条链表就把整个图表示了。
如果是无向边呢?那很简单,就把无向边理解成双向的就行。比如说 u和v之间有一条无向边,权为w,那么 edge[u] 中就会有一个结点,它存放的边的信息是 {v, w},同样在 edge[v] 中也会有一个结点,它存放的边的信息是 {u, w}。
具体操作:
- 添加边:
- 对于有向图,插入 u->v 权重为 d 的边(无权图则 d 可视为1,表示导通): edge[u].push_back(Edge(v, d));
- 对于无向图,对称地进行两次操作即可,即:edge[u].push_back(Edge(v, d)), edge[v].push_back(Edge(u, d));
- 修改边权或者删边:
- 需要遍历一个链表,再修改边权,O(n) 内完成(好吧如果用平衡树代替链表可以实现O(log n),用哈希表甚至更低,但此种做法极少见)
- 遍历单点出发的所有邻接点:
- 遍历该点对应的链表,O(n) 内完成(不会像邻接矩阵那样在不连通点浪费时间)
代码示例:
首先是双向链表实现的邻接表:
/* 邻接表图(双向链表实现):
*
* 顶点编号:0 ~ MAX_V-1
* 示例函数:链表的构建、图的初始化、边的插入、简单的遍历
*/
#include <iostream>
#include <cstdio>
#include <cstring>
constexpr int MV(103);
// 针对邻接表而打造的简单链表,可以只实现最基本的emplace_back
template <typename T>
struct List
{
struct Node
{
T data;
Node *prev;
Node *next;
Node(void) { }
Node(const T &data, Node *prev, Node *next) :
data(data), prev(prev), next(next) { }
};
Node *head;
Node *tail;
int cnt;
List(void) :
head(nullptr), tail(nullptr), cnt(0) { }
~List(void)
{
this->clear();
}
void clear(void)
{
for (Node *p=head, *q; p; p=q)
{
q = p->next;
delete p;
}
head = tail = nullptr;
cnt = 0;
}
//
// inline int size(void)
// {
// return cnt;
// }
//
// inline bool empty(void)
// {
// return cnt == 0;
// }
//
// inline T &front(void)
// {
// return head->data;
// }
//
// inline T &back(void)
// {
// return tail->data;
// }
//
inline void emplace_back(const T &data)
{
if (cnt)
tail = tail->next = new Node(data, tail, nullptr);
else
head = tail = new Node(data, nullptr, nullptr);
++cnt;
}
//
// inline void emplace_front(const T &data)
// {
// if (cnt)
// head = head->prev = new Node(data, nullptr, head);
// else
// head = tail = new Node(data, nullptr, nullptr);
// ++cnt;
// }
//
// inline void pop_back(void)
// {
// --cnt;
// if (cnt)
// {
// tail = tail->prev;
// delete tail->next;
// tail->next = nullptr;
// }
// else
// {
// delete tail;
// tail = head = nullptr;
// }
// }
//
// inline void pop_front(void)
// {
// --cnt;
// if (cnt)
// {
// head = head->next;
// delete head->prev;
// head->prev = nullptr;
// }
// else
// {
// delete head;
// head = tail = nullptr;
// }
// }
};
template <typename WeightType, int MAX_V>
class AGraph
{
private:
struct Edge // 边的结构体
{
int dest;
WeightType weight;
Edge(void) { }
Edge(const int dest, const WeightType weight) :
dest(dest), weight(weight) { }
};
int V; // 顶点个数 V
bool vis[MAX_V]; // 用于下面的 DFS 演示
List<Edge> edge[MAX_V]; // 存图的主要结构,链表数组
public:
// 初始化图:把顶点数记下来,方便后面的遍历
void init(const int V)
{
this->V = V;
}
// 每次对图操作完毕后需要清空图的链表数组,避免妨碍下次使用
void clear(void)
{
for (int i=0; i<V; ++i)
edge[i].clear();
}
// 向图中添加一条边
inline void add_edge(const int u, const int v, const WeightType w)
{
edge[u].emplace_back(Edge(v, w));
}
/* 图的应用(这里用简单的 DFS 来体现一下图的遍历):
* 主要是展示一下如何遍历从某点出发的所有邻接点
*/
inline void init_dfs(void)
{
memset(vis, false, sizeof(*vis) * (V));
}
void dfs(const int src)
{
vis[src] = true;
for (auto p=edge[src].head; p; p=p->next) // 从src出发,遍历其所有邻接点(遍历链表)
{
int dest = p->data.dest, weight = p->data.weight;
if (!vis[dest])
{
std::cout << src << "->" << dest << ": " << weight << std::endl;
dfs(dest);
}
}
}
};
AGraph<int, MV> graph;
int main()
{
graph.init(6);
graph.add_edge(0, 1, 2);
graph.add_edge(0, 5, 3);
graph.add_edge(1, 2, 5);
graph.add_edge(1, 3, 6);
graph.add_edge(1, 5, 9);
graph.add_edge(2, 3, 6);
graph.add_edge(2, 4, 4);
graph.add_edge(3, 4, 3);
graph.add_edge(3, 5, 6);
graph.init_dfs();
graph.dfs(0);
graph.clear();
}
当然也可以用std::list实现邻接表:
(代码基本上和上面自己实现的是一样的,因为链表的接口是统一的)
/* 邻接表图(std::list 实现):
*
* 顶点编号:0 ~ MAX_V-1
* 示例函数:链表的构建、图的初始化、边的插入、简单的遍历
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <list>
constexpr int MV(103);
template <typename WeightType, int MAX_V>
class AGraph
{
private:
struct Edge // 边的结构体
{
int dest;
WeightType weight;
Edge(void) { }
Edge(const int dest, const WeightType weight) :
dest(dest), weight(weight) { }
};
int V; // 顶点个数 V
bool vis[MAX_V]; // 用于下面的 DFS 演示
std::list<Edge> edge[MAX_V]; // 存图的主要结构,链表数组
public:
// 初始化图:把顶点数记下来,方便后面的遍历
void init(const int V)
{
this->V = V;
}
// 每次对图操作完毕后需要清空图的链表数组,避免妨碍下次使用
void clear(void)
{
for (int i=0; i<V; ++i)
edge[i].clear();
}
// 向图中添加一条边
inline void add_edge(const int u, const int v, const WeightType w)
{
edge[u].emplace_back(Edge(v, w));
}
/* 图的应用(这里用简单的 DFS 来体现一下图的遍历):
* 主要是展示一下如何遍历从某点出发的所有邻接点
*/
inline void init_dfs(void)
{
memset(vis, false, sizeof(*vis) * (V));
}
void dfs(const int src)
{
vis[src] = true;
for (auto e : edge[src]) // 从src出发,遍历其所有邻接点(遍历链表)
{
int dest = e.dest, weight = e.weight;
if (!vis[dest])
{
std::cout << src << "->" << dest << ": " << weight << std::endl;
dfs(dest);
}
}
}
};
AGraph<int, MV> graph;
int main()
{
graph.init(6);
graph.add_edge(0, 1, 2);
graph.add_edge(0, 5, 3);
graph.add_edge(1, 2, 5);
graph.add_edge(1, 3, 6);
graph.add_edge(1, 5, 9);
graph.add_edge(2, 3, 6);
graph.add_edge(2, 4, 4);
graph.add_edge(3, 4, 3);
graph.add_edge(3, 5, 6);
graph.init_dfs();
graph.dfs(0);
graph.clear();
}
然后是大家使用最多的std::vector实现邻接表:
(仅进行emplace_back的话,std::vector的性能将优于std::list(后者时间复杂度常数更大))
仅仅把上面的代码 ctrl+R 将 list 替换 为 vector 即可,可见STL在接口统一上做了巨大的努力。
/* 邻接表图(std::vector 实现):
*
* 顶点编号:0 ~ MAX_V-1
* 示例函数:链表的构建、图的初始化、边的插入、简单的遍历
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
constexpr int MV(103);
template <typename WeightType, int MAX_V>
class AGraph
{
private:
struct Edge // 边的结构体
{
int dest;
WeightType weight;
Edge(void) { }
Edge(const int dest, const WeightType weight) :
dest(dest), weight(weight) { }
};
int V; // 顶点个数 V
bool vis[MAX_V]; // 用于下面的 DFS 演示
std::vector<Edge> edge[MAX_V]; // 存图的主要结构,链表数组
public:
// 初始化图:把顶点数记下来,方便后面的遍历
void init(const int V)
{
this->V = V;
}
// 每次对图操作完毕后需要清空图的链表数组,避免妨碍下次使用
void clear(void)
{
for (int i=0; i<V; ++i)
edge[i].clear();
}
// 向图中添加一条边
inline void add_edge(const int u, const int v, const WeightType w)
{
edge[u].emplace_back(Edge(v, w));
}
/* 图的应用(这里用简单的 DFS 来体现一下图的遍历):
* 主要是展示一下如何遍历从某点出发的所有邻接点
*/
inline void init_dfs(void)
{
memset(vis, false, sizeof(*vis) * (V));
}
void dfs(const int src)
{
vis[src] = true;
for (auto e : edge[src]) // 从src出发,遍历其所有邻接点(遍历链表)
{
int dest = e.dest, weight = e.weight;
if (!vis[dest])
{
std::cout << src << "->" << dest << ": " << weight << std::endl;
dfs(dest);
}
}
}
};
AGraph<int, MV> graph;
int main()
{
graph.init(6);
graph.add_edge(0, 1, 2);
graph.add_edge(0, 5, 3);
graph.add_edge(1, 2, 5);
graph.add_edge(1, 3, 6);
graph.add_edge(1, 5, 9);
graph.add_edge(2, 3, 6);
graph.add_edge(2, 4, 4);
graph.add_edge(3, 4, 3);
graph.add_edge(3, 5, 6);
graph.init_dfs();
graph.dfs(0);
graph.clear();
}
以上即为两种最基础的存图结构 —— 邻接矩阵与邻接表的介绍。
链式前向星将在下一篇文章中进行分析:【最短路题型总结 II】【存图结构】链式前向星 | CGUZ | N