前置技能点:链式前向星
简介:
- 更为经济、简练 的存图结构。
- 是 前向星的改进(普通前向星和模拟链表还有一定的区别。普通前向星的建图过程涉及到排序操作,开销较大)
- 链式前向星巧妙地利用 结构体数组 模拟了一个链表,并通过头插法进行边的插入操作。
- 除了 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数组中的位置
} edge[MAX_V];
int tot;
int head[MAX_V]; //i顶点发出的边在edge数组中的位置(头指针)
② 链式前向星的遍历
for (int i=head[1]; i!=-1; i=edge[i].next)
{
std::cout << edge[i].weight << std::endl;
}
③ 链式前向星的构建(边的插入)
链式前向星是用了头插法(也就是push_front)来做边的插入。
++tot; // 新增加一个结点
edge[tot].next = head[u]; // 让新结点的next指针指向现在的head,相当于在head的前面插入新结点
edge[tot].dest = v, edge[tot].weight = w; // 初始化这个新结点的数据
head[u] = tot; // 使这个新结点成为链表的新head
具体操作:
添加边:插入 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;
代码:
/* 链式前向星图(结构体数组 实现):
*
* 顶点编号: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);
}
四种最短路算法
最短路
最短路,顾名思义,最短的路径。
我们把边带有权值的图称为带权图。边的权值可以理解为两点之间的距离。一张图中任意两点之间会有不同的路径相连。最短路径就是指连接两点的这些路径中最短的一条。
我们有四种算法可以有效地解决最短路径问题,但是当出现负边权时,有些算法不适用。
稠密图与稀疏图
由于各种算法对边和点的处理不尽相同,所以我们可以根据图的不同来选择不同的建图方式与最短路算法。
单源最短路径算法与多源最短路径算法
单源最短路径算法是用来计算从一个点到其他所有点的最短路径算法;
多源最短路径算法是用来计算任意两点之间的最短路径算法。
以下若无特别说明,dis[i][j]代表从i到j的最短路径,w[i][j]代表连接i,j的边的长度。
Floyed-Warshall算法 O(N^3)
分类:
多源最短路径算法。
作用:
1.求最短路。 2.判断一张图中的两点是否相连。
优点:
实现极为简单
缺点:
时间复杂度高
思想:
3层循环,第一层枚举中间点k,第二层与第三层枚举两个端点i,j。若有dis[i][j] > dis[i][k] + dis[k][j] 则把dis[i][j]更新成dis[i][k] + dis[k][j](原理还是很好理解的)。
实现:
初始化:点i,j如果有边相连,则dis[i][j] = w[i][j]。如果不相连,则dis[i][j] = 0x7fffffff(int极限值),表示两点不相连(或认为相隔很远)。
for(int k = 1; k <= n; k++) //枚举中间点(必须放最外层)
for(int i = 1; i <= n; i++) //枚举端点i
if(i != k)
for(int j = 1; j <= n; j++) //枚举端点j
if(i != j && j != k && dis[i][j] > dis[i][k] + dis[k][j])
dis[i][j] = dis[i][k] + dis[k][j];
Dijkstra迪杰斯特拉算法 O(N^2)
分类:
单源最短路径算法。
适用于:
稠密图(侧重对点的处理)。
时间复杂度:
1.朴素:O(N^2)
2.堆优化:O(n * logn)
缺点:
不能处理存在负边权的情况。
算法思想:
把点分为两类,一类是已经确定最短路径的点,称之为“标记点”;另一类则是还未确定最短路径的点,称之为“未标记点”。如果要求出一个点的最短路径,就是把这个“未标记点”变成“标记点”,从起点到“未标记点”的最短路径上的中转点在这个时刻只能是“标记点”。
Dijkstra的算法思想,就是一开始将起点到起点的距离标记为0,而后进行n次循环,每次找出一个到起点距离dis[u]最短的点u,将它从“未标记点”变为“标记的点”。随后枚举所有的“未标记的点”vi,如果以此“标记的点”为中转点到达“未标记的点”vi的路径dis[u] + w[u][vi]更短的话,这将它作为vi的“更短路径”dis[vi](此时还不确定是不是vi的最短路径)。
就这样,我们每找到一个“标记的点”,就尝试着用它修改其他所有的:“未标记的点”,故每一个终点一定能被它的最后一个中转点所修改,而求得最短路径。
优化思想:
利用堆(优先队列),把冗杂的枚举查找变成更加快速的堆直接弹出。堆优化参考:传送门
实现思路:
(a)初始化:dis[v] = 无穷大(v != s); dis[s] = 0; pre[s] = 0;
(b)for(int i = 1; i <= n; i++)
1.在没有被访问过的点中找一个顶点u使得dis[u]是最小的。
2.u标记为已确定最短路径。
3.for与u相连的每个未确定最短路径的顶点v。
#include<bits/stdc++.h>
using namespace std;
const int MAX_M = 1e3+5;
const int INF = 0x3f3f3f3f;
int tot;
int head[MAX_M]; //i顶点发出的边在edge数组中的位置(头指针)
int dis[MAX_M],vis[MAX_M];
struct Edge
{
int dest; //边的终点
int weight; //边的权重
int next; //下一条边在edge数组中的位置
} edge[MAX_M];
struct node
{
int pos,val;
friend bool operator<(node a,node b)//运算符函数
{
return a.val>b.val;
}
};
void add(int s,int e,int l)
{
edge[++tot].next=head[s];
edge[tot].dest=e;
edge[tot].weight=l;
head[s]=tot;
}
void dijkstra(int s,int e)
{
priority_queue<node> q;
q.push(node{s,0});
dis[s]=0; //与自己的距离为0
while(!q.empty())
{
node u=q.top();
q.pop();
if(vis[u.pos])
continue; //若该点已经被更新到最优,就不用再次更新其他点
vis[u.pos] = 1;
for(int i=head[u.pos]; i!=0; i=edge[i].next)
{
int dest=edge[i].dest,weight=edge[i].weight;
if(dis[dest]>u.val+weight)
{
dis[dest]=u.val+weight;
q.push(node{dest,dis[dest]});
}
}
}
}
Bellman-Ford算法 O(NE)
分类:
单源最短路径算法。
适用于:
稀疏图(侧重于对边的处理)。
优点:
可以求出存在负边权情况下的最短路径。
缺点:
无法解决存在负权回路的情况。
时间复杂度:
O(NE),N是顶点数,E是边数。(因为和边有关,所以不适于稠密图)
算法思想:
很简单。一开始认为起点是“标记点”(dis[1] = 0),每一次都枚举所有的边,必然会有一些边,连接着“未标记的点”和“已标记的点”。因此每次都能用所有的“已标记的点”去修改所有的“未标记的点”,每次循环也必然会有至少一个“未标记的点”变为“已标记的点”。
算法实现:
初始化:dis[s] = 0; dis[v] = oo(v != s); pre[s] = 0;
for(int i = 1; i <= n - 1; i++)
for(int j = 1; j <= E; j++) //注意要枚举所有边,不能枚举点
if(dis[u] + w[j] < dis[v]) //u, v分别是这条边连接的两个点
{
dis[v] = dis[u] + w[j]
pre[v] = u;
}
SPFA算法O(KE)
适用于:
稀疏图(侧重于对边的处理)。
时间复杂度:
O(KE),K是常数,平均值为二,E是边数。(因为和边有关,所以不适于稠密图)
来源:
SPFA是Bellman-Ford算法的一种队列实现,减少了不必要的冗余计算。
这个算法简单地说就是队列优化的Bellman-Ford,利用了每个点不会更新次数太多的特点发明的此算法。
SPFA在形式上和广度优先搜索非常类似,不同的是广度优先搜索中的一个点出了队列就不可能重新进入队列,但是SPFA中的一个点可能在出队列之后再次被放入队列,也就是说一个点修改过其他的点之后,过了一段时间可能会获得更短的路径,于是再次用来修改其他的点,这样反复进行下去。
优化方法:
1.循环队列(可以降低队列大小)
2.SLF:Small Label First 策略,设要加入的节点是j,队首元素为i,若dist(j) < dist(i),则将j插入队首,否则插入队尾。
if(!vis[temp])
{
if(dis[q[head + 1]] < dis[temp]) //注意小于号不要写反,否则时间会爆
{
tail = (++tail - 1) % qxun + 1;
q[tail] = temp;
}
else
{
q[head] = temp;
if(--head == 0) head = qxun;
}
vis[temp] = 1;
}
3.LLL:Large Label Last 策略,设队首元素为i,每次弹出时进行判断,队列中所有dist值的平均值为x,若dist(i)>x则将i插入到队尾,查找下一元素,直到找到某一i使得dist(i)<=x,则将i出对进行松弛操作。