在图论中,最短路是一个基础型重点算法问题,也是在实际工程中存在的经典问题。因此有必要在最短路上花些功夫。
这一篇介绍 链式前向星 :
链式前向星(实际上就是用结构体数组 模拟 使用头插法的链表)
简介:
- 更为经济、简练 的存图结构。
- 是 前向星的改进(普通前向星和模拟链表还有一定的区别。普通前向星的建图过程涉及到排序操作,开销较大)
- 链式前向星巧妙地利用 结构体数组 模拟了一个链表,并通过头插法进行边的插入操作。
- 除了 Dijkstra 和 SPFA,在 差分约束系统、网络流、二分图匹配 等其他经典的图论问题中也常常能看见链式前向星的身影。
适用情况:(链式前向星本质上还是邻接表,所以适用情况同邻接表)
- 存储稀疏图(顶点个数 V 没有很强的限制,边条数 E 的数量级 比 V² 低许多)
- 需要快速地对某个点的 所有邻接点 / 发出的所有边 进行遍历
- 尽量无重边、不删边、不查询边权(因为对边的查、改会比较耗时)
算法搭配:(链式前向星本质上还是邻接表,所以算法搭配同邻接表)
- 链式前向星 + Dijkstra:处理稀疏图的单源最短路径问题。使用堆优化的 Dijkstra 能显现出更优秀的性能(图较稀疏,简单遍历求最近未收录点将会带来较大的开销)
- 链式前向星 + SPFA:处理稀疏图的单源最短路径问题。还可以应对负权边和检测负环。因此在处理常常出现负权边的差分约束系统问题时也常常会使用邻接表 + SPFA。
- 链式前向星 + Kruskal:处理稀疏图的最小生成树问题。
基本介绍:
邻接表是实实在在的链表数组,链式前向星是模拟的链表数组。
那它是如何模拟的呢?我们先把链式前向星的定义看一看:
① 链式前向星的定义
链式前向星的定义一共有三部分:
- 边集数组 edge[MAX_V],它存储着所有的边。
- 计数器 tot,它记录着现在边集数组中已经存储的边数。
- 头指针数组 head[MAX_V],存储头指针
struct Edge
{
int dest;
int weight;
int next;
} edge[MAX_V];
int tot;
int head[MAX_V];
这就是链式前向星。我们说链式前向星其实就是一个模拟的链表数组,那链表是怎么体现出的呢?
② 链式前向星的遍历
首先,链表的头就在head数组里。比如,head[1] 的值就是 1 号顶点发出的第一条边在edge数组中的下标。也就是说 edge[head[1]] 就是 1 号顶点发出的第一条边。
那第二条在哪里呢?别忘了edge[head[1]]是一个结构体,它的里面有一个next。edge[head[1]].next 的值也就是第二条边在 edge 数组中的下标。所以这个 next 就相当于传统链表的结点的next指针。
假设u一共发出了x条边,
那么u发出的第一条边的next(edge[head[1]].next)就是u发出的第二条边在edge数组中的下标
u发出的第二条边的next(edge[edge[head[1]].next].next)就是u发出的第三条边在edge数组中的下标
......这样一直链下去,就构成了一条链表。
那什么时候终止呢?在非循环链表中,next指针为NULL的时候就终止了;在链式前向星中,结构体里面的next为-1的时候就终止了(如果不停止,那next是-1就说明下一条边在edge数组中的下标是 -1,这显然是不对的)
好,通过以上操作我们就把 head[1] 起头的这一条链表给找出来了。如果要写一个循环打印过程中每一条边的权重,那么就应该这样写:
for (int i=head[1]; i!=-1; i=edge[i].next)
{
std::cout << edge[i].weight << std::endl;
}
这就是遍历 head[1] 起头的整条链表的过程,也就是遍历顶点1发出的所有的边。
这个循环在自己用链表实现的邻接表中的代码则应该是这样的,大家可以对比一下:
for (Node *p=edge[1].head; p!=nullptr; p=p->next)
{
std::cout << p->data.weight << std::endl;
}
而如果是vector实现的邻接表,那么代码就又稍微有一些不同:
for (int i=0; i<edge[1].size(); ++i)
{
std::cout << edge[1][i].weight << std::endl;
}
③ 链式前向星的构建(边的插入)
上面分析了链式前向星的定义和遍历,那么 链式前向星是怎么进行边的插入的呢?
回想一下邻接表,邻接表插入边很简单,潇洒地push_back就ok了
链式前向星呢?链式前向星刚好相反,它其实是用了头插法(也就是push_front)来做边的插入。
先看一下普通链表是怎么做头插法(push_front)的(假设这个链表的名字是list,待插入结点的数据是 data):
Node *pNewNode = new Node; // 新增加一个结点
pNewNode->next = list.head; // 让新结点的next指针指向现在的head,相当于在head的前面插入新结点
pNewNode->data = data; // 初始化这个新结点的数据
list.head = pNewNode; // 使这个新结点成为链表的新head
这样就在原来链表的头的前面插入了一个新结点。那链式前向星是怎么做的呢?是这样:(假设待插入的边是 u->v、权为w的)
++tot; // 新增加一个结点
edge[tot].next = head[u]; // 让新结点的next指针指向现在的head,相当于在head的前面插入新结点
edge[tot].dest = v, edge[tot].weight = w; // 初始化这个新结点的数据
head[u] = tot; // 使这个新结点成为链表的新head
可见二者是完全对应的。
所以链式前向星就是通过头插法(push_front)来进行边的插入的。(当然第一和第二步可以合并,变成 edge[++tot].next = head[u];)这样,链式前向星的插入边的函数的逻辑就明晰了。
具体操作:
- 添加边:插入 u 和 v 之间的、权为 d 的边
- 如果是单向边:
edge[++tot].next = head[u]; edge[tot].dest = v; edge[tot].weight = w; head[u] = tot;
如果是双向边:
edge[++tot].next = head[u]; edge[tot].dest = v; edge[tot].weight = w; head[u] = tot; edge[++tot].next = head[v]; edge[tot].dest = u; edge[tot].weight = w; head[v] = tot;
- 如果是单向边:
- 修改边权或者删边:
- 需要进行遍历,一般不建议进行此操作。O(n) 内完成
- 遍历单点出发的所有邻接点:
- O(n) 内完成
for (int i=head[v]; i!=-1; i=edge[i].next) { // do something... }
- O(n) 内完成
代码示例:
/* 链式前向星图(结构体数组 实现):
*
* 顶点编号:0 ~ MAX_V-1
* 示例函数:图的初始化、边的插入、简单的遍历
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
constexpr int MV(103);
template <typename WeightType, int MAX_V>
class SGraph
{
private:
struct Edge // 链式前向星边的结构体
{
int dest;
WeightType weight;
int next;
Edge(void) { }
Edge(const int dest, const WeightType weight) :
dest(dest), weight(weight) { }
};
int V; // 顶点个数 V
bool vis[MAX_V]; // 用于下面的 DFS 演示
Edge edge[MAX_V]; // 存图的主要结构,边集数组
int head[MAX_V]; // 存图的主要结构,相当于模拟链表的头指针
int tot; // 当前使用链式前向星存储的边的条数
public:
/* 初始化图:
* 把顶点数记下来,方便后面的遍历
* 把链式前向星的头指针数组初始化,-1表示不导通
* 把链式前向星当前存的边数也置0
*/
void init(const int V)
{
this->V = V;
memset(head, -1, sizeof(*head) * V);
tot = 0;
}
// 向图中添加一条边
inline void add_edge(const int u, const int v, const WeightType w)
{
edge[tot].next = head[u]; // 这句话就相当于 把新结点的next指针设为链表头
edge[tot].dest = v; // 这句话是对新增结点的数据进行赋值
edge[tot].weight = w; // 这句话是对新增结点的数据进行赋值
head[u] = tot++; // 这句话就相当于 把旧的链表头 改成 新的结点
}
/* 图的应用(这里用简单的 DFS 来体现一下图的遍历):
* 主要是展示一下如何遍历从某点出发的所有邻接点
*/
inline void init_dfs(void)
{
memset(vis, false, sizeof(*vis) * (V));
}
void dfs(const int src)
{
vis[src] = true;
for (int i=head[src]; ~i; i=edge[i].next) // 从src出发,遍历其所有邻接点(遍历链表)
{
int dest = edge[i].dest, weight = edge[i].weight;
if (!vis[dest])
{
std::cout << src << "->" << dest << ": " << weight << std::endl;
dfs(dest);
}
}
}
};
SGraph<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);
}
那么关于最短路的数据结构基础就介绍到这里啦,接下来的【最短路题型总结 III】,就直接开始分析单源最短路算法了。
URL:【最短路题型总结 III】【单源最短路算法】Dijkstra(+Heap) | SPFA(+SLF+LLL) | CGUZ | N
加油!