简单图

图的定义及其相关概念

参考资料:度娘
度娘:在数学中,图是描述于一组对象的结构,其中某些对象对在某种意义上是“相关的”。这些对象对应于称为顶点的数学抽象(也称为节点或点),并且每个相关的顶点对都称为边(也称为链接或线)。通常,图形以图解形式描绘为顶点的一组点或环,并通过边的线或曲线连接。 图形是离散数学的研究对象之一。
我:一些结点的集合\(V\),一些连接结点的边的集合\(E\)组成的图\(G\),称为\(G(V,E)\)
我们可以通过这些边从一个结点“走”到另一个结点
如果这个过程只能单向进行,那么就称这条边为有向边
如果我们能双向走,那么就称这条边为无向边
那么,由有向边组成的图就叫做有向图,由无向边组成的图就叫做无向图
特殊的,两个结点互相连有向边也可以实现一条无向边互通的功能。但这里还是有向边,所以还是有向图。
至于单图……就是没有重边,没有自环也没有的图

  • 重边:两条边起点的终点相同,就称他们为重边。
  • 自环:一条边起点和终点都是自己,就称这条边是自环。
  • 环:图中起点和终点相同的路径
  • 路径:从一个点慢慢走,随便走,爱怎么走怎么走反正能走到就行了,所经过的边的有序序列称为路径。如果它的起止顶点相同,该路径是“闭”的,反之,则称为“开”的。
  • 简单路径:路径中除了起点和终点满足所有点各不相同(就是除了走回起点的情况外,不重复经过同一个点。不一定要走回起点。)
  • 行迹:不重复经过同一条边的路径
  • 轨道:不重复经过同一个点的路径
  • 回路:闭的行迹
  • 圈:闭的轨道
  • 连通:从图中点\(i\)可以“走”到点\(j\)就称\(i\)\(j\)连通,此时\(j\)\(i\)不一定连通。
  • 连通图:图中任意两个点都互相连通
  • 强联通图:连通的有向图
  • 桥:连通图中,去掉一条边使得图不连通,称这条边为桥,也称为割边。

图的存储方式

设一个图的点数为\(n\),边数为\(m\)

邻接矩阵

就是一个阵,是一个\(n\times n\)的矩阵,矩阵中的元素\(a_{i,j}\)表示结点\(i\)到结点\(j\)之间的连边的长度。如果没有边相连,那么可以令\(a_{i,j}=\infty\)
比如说下面这个邻接矩阵:

\[\begin{array}{|c|c|c|} \hline 0&1&2\\ \hline 3&0&5\\ \hline 8&\infty&0\\ \hline \end{array} \]

他就表示一共有三个点,因为每个点都没有连向自己的边,但是想深一层,自己是可以“走”到自己的,所以令方阵的主对角线为0
然后\(a_{1,2}=1\)就表示结点\(1\)连向结点\(2\)的边的长度为\(1\)
\(a_{3,2}=\infty\)表示结点\(3\)\(2\)没有边相连。
然后我们看上面的矩阵,很明显是表示一个有向图。因为无向图的矩阵关于主对角线对称


然后有时候我们也会用这个矩阵来表示两个点之间的最短路径长度
初始的时候我们也一样用矩阵来存储边的长度,主对角线都给0,没有边相连的都给\(\infty\)
然后通过什么floyd,dij,spfa之类的玄学算法求得最短路。
但是要注意在有负环的图中不存在最短路

  • 负环:一个环,环上所有的边的权值之和为负数

相对应的也有

  • 零环:一个环,环上所有边的权值之和为0

这里用floyd算法举例

floyd算法多源最短路

多源最短路是相对于单源最短路来说的。单源最短路就是说这个算法跑一次只能求出一个起点到任意终点的最短路。而多源最短路就是跑一次可以求出任意起点到任意终点的最短路。
Q:我不能让多源最短路跑一次只求一个起点吗?
A:不能。多源最短路算法如floyd求解的过程中需要用到其他结点的最短路。你可以不保存结果,但是不能不求。
那现在来讲一下floyd的思想:
首先我们可以知道,两个点\(i,j\)之间的最短路径如果不是只有一条边,那么就肯定会通过一个中继结点\(k\)
那么在得出来的最短路网络中应该会有\(dis_{i,k}+dis_{k,j}=dis_{i,j}\)(如果\(i\)\(j\)的最短路是通过\(k\)这个中继结点走到的话)
那么我们就强制在寻找\(i\)\(j\)的最短路中,我们必须经过\(k\)这个结点,就可以知道所有的通过这个结点的路径长度为\(dis_{i,k}+dis_{k,j}\)如果这个值比\(dis_{i,j}\)小就更新\(dis_{i,j}\)
然后我们需要重复枚举\(k\),从\(1\)\(n\)
然后我们也要枚举\(i,j\)\(1\)\(n\)
注意枚举的时候最外层枚举中继结点\(k\),第二层枚举起点\(i\),第三层枚举终点\(j\)

for(int k=1;k<=n;++k) for(int i=1;i<=n;++i) for(int j=1;j<=n;++j) dis[i][j]=std::min(dis[i][j],dis[i][k]+dis[k][j]);

可以说是思维难度和代码实现最简单的最短路……
总的时间复杂度就是\(O(n^3)\)
推荐在数据范围100以内使用
我们看看这道题
首先可以发现这道题数据范围不符合要求
但是他告诉我们可以验证floyd的正确性
那么我们。。。就验证一下我们写的代码是对的就行了

#include <bits/stdc++.h>
using namespace std;
int n,m,a,b,c;
long long int dis[101][101];
bool floyd();
void print(int);
int main()
{
	scanf("%d%d",&n,&m);
	// 初始化,到自己的距离为0,到其他结点没有路径(极大值,但是自加之后不能溢出) 
	for(int i=1;i<=n;++i) for(int j=1;j<=n;++j) if(i==j) dis[i][j]=0; else dis[i][j]=LLONG_MAX>>1;
	for(int i=1;i<=m;++i)
	{
		scanf("%d%d%d",&a,&b,&c);
		if(dis[a][b]>c) dis[a][b]=c; // 可能有重边,所以找最小的 
	}
	if(floyd()) puts("-1");
	else for(int i=1;i<=n;++i) print(i);
	return 0;
}

bool floyd()
{
/*
	用floyd求多源最短路 
	@param: none
	@return: bool 表示是否有负环 
*/
	for(int k=1;k<=n;++k) for(int i=1;i<=n;++i) for(int j=1;j<=n;++j)
	{
		if(dis[i][k]+dis[k][j]<dis[i][j]) dis[i][j]=dis[i][k]+dis[k][j]; // 通过中继节点k找到比目前短的i,j之间的最短路 
		if(dis[i][i]<0) return true; // 自己还能被更新成小于0?不是吧肯定有负环 
	}
	return false; // 中间都没有return 到了最后肯定就是没有负环 
}

void print(int i)
{
/*
	输出答案
	@param: int i 当前输出第i行 
	@return: void
*/
	long long int res=0;
	for(long long int j=1;j<=n;++j)
	{
		if(dis[i][j]>1000000000) dis[i][j]=1000000000; // 表示没有路径,赋值为1e9 
		res+=j*dis[i][j];	// 统计 
	}
	printf("%lld\n",res);
	return;
}

好了20pts到手

邻接表

其实我觉得叫邻接链表更亲切……
不知道链表是什么的小可爱点这里
对于一个点,用链表存储他指向的所有点。
这样访问的效率比用邻接矩阵快。。。吧
缺点是要知道某个点是否被链接的效率较慢。
可以用链表实现,也可以用数组实现。
可以快速遍历所连接的所有结点。
这个就是邻接表
然后我们发现有重边怎么办?
加多一个结点呗。。。
然后我们发现邻接表是存储指向的点。
那么如果我们改成
对于每个结点存储他的第一条边(或者叫最后一条边)(也就知道了指向的第一个结点)
对于每一条边存储指向的结点,权值(如果有),以及指向下一条边(连接的下一个结点)
也就是每一个结点的链表存储所有他连的边
这种方法叫做前向星
由于前向星实在太火了。。。所以我不会邻接表我用前向星演示一下单源最短路的做法:
为什么前向星/邻接表比较适合单源最短路呢?
不知道

dij单源最短路

好像叫什么dijkstra来着。。。
就是这个人发明的↑
主要思想:把点分为两种,一种已经确定为最短,一种未确定为最短。
开始的时候先把起点到所有点的距离\(dis_i\)都设为\(\infty\)
然后起点\(s\)到起点\(s\)的距离\(dis_s\)设为\(0\)
重复执行\(n\)次下列操作(就是直到所有的点都是已确定为最短):

  1. 从未确定为最短的点集中找到距离\(dis_i\)最小的点\(i\)
  2. \(i\)标记为已确定最短
  3. 遍历\(i\)连向的所有结点。对于所有未标记为最短且\(dis_j>dis_i+w_{i,j}\)的点\(j\),更新\(j\)的dis为\(dis_j=dis_i+w_{i,j}\),其中\(w_{i,j}\)\(i\)连向\(j\)的一条的权值。对于无权图为1
  4. \(j\)不标记(原来就没有标记现在标什么标)
    我们可以发现,这样的时间复杂度大概是\(O(n(n+m))\)左右,因为每次我们找到未确定为最短中最小的都需要\(O(n)\),所以很慢。
    然后我们如果用堆/优先队列来维护这个最小值,可以把复杂度降到\(O(\log_2n)\)左右
#include <cstring>
#include <cstdio>
#include <queue>
#include <vector>
#include <utility>
unsigned n,m,s,a,b,c;
unsigned dis[100001];
bool vis[100001];
struct Edge
{
/*
	储存边的结构体 
	@ param : unsigned u 上一条边
			  unsigned v 这条边的终点
			  unsigned w 这条边的权值 
*/
	unsigned u,v,w;
}edge[200001];
int head[100001],tot=0;
inline void add(int a,int b,int c=1)
{
/*
	在图中增加一条有向边边 
	@ param : unsigned a 有向边的起点 
			  unsigned b 有向边的终点
			  unsigned c 有向边的权值
	@ return: void 
*/
	// head[a]表示这个点的第一条边。因为新扔了一条边进来所以新加入的边的上一条边就是原来的第一条边 。第一条边更新为新的边
	// tot表示动态开边的现在总边数 
	edge[++tot]=(Edge){head[a],b,c}; head[a]=tot;
	return;
}
void dij(unsigned s)
{
/*
	用dij算出单元最短路 
	@ param : unsigned s 起点
	@ return: void 
*/
	std::memset(dis,0xff,sizeof(dis));
	std::memset(vis,0x00,sizeof(vis));
	dis[s]=0;
	// 使用优先队列来实现堆。堆中储存的是二元集(dis[i],i),以dis[i]升序排列 
	std::priority_queue<std::pair<unsigned,unsigned>,std::vector<std::pair<unsigned,unsigned> >,std::greater<std::pair<unsigned,unsigned> > > q;
	q.push(std::make_pair(0,s));
	while(!q.empty())
	{
		// 取出dis[i]最小的点 
		unsigned i=q.top().second; q.pop();
		// 如果打了标记那么就舍弃,取出下一个。否则打标记。 
		if(vis[i]) continue; vis[i]=true;
		for(int j=head[i];j;j=edge[j].u)
		{
			/// 一条条边访问
			// 如果终点打了标记,跳过 
			if(vis[edge[j].v]) continue;
			// 如果可以更新,更新并入堆 
			if(dis[edge[j].v]>dis[i]+edge[j].w) q.push(std::make_pair(dis[edge[j].v]=dis[i]+edge[j].w,edge[j].v));
		}
	}
	return;
}
int main()
{
	scanf("%u%u%u",&n,&m,&s);
	while(m--)
	{
		scanf("%u%u%u",&a,&b,&c);
		add(a,b,c);
	}
	dij(s);
	for(int i=1;i<=n;++i) printf("%u ",dis[i]);
	puts("");
	return 0;
}

spfa单源最短路

dij的本质是贪心算法,每次取一个距离最小的点确定最短路。这个贪心只有在边权非负的时候才是正确的。边权非负就说明若当前是\(dis[i]\),那么这个点的最短路不会大于\(dis[i]\)(你都多绕了怎么可能更新嘛。。。)
但是有负边权就不一定了。
有可能还能更新,那这个贪心的正确性就不能保证了。
所以就出来了一个很有争议的算法:spfa
spfa是一个对于Bellman-ford算法的队列优化。由于这个算法太出名了。。。所以Bellman-ford算法我已经忘记了。。。
首先队列,起点。
还需要一个数组表示结点是否在队列中
暂且用\(vis_i\)表示
主要步骤

  1. 起点入队
  2. 从队头取出一个结点(标记不在队列中)
  3. 遍历这个点链接的所有结点
    3.1 如果可以松弛操作就松弛操作,并且把指向的点入队(前提是这个点本身不在队列中)
  4. 如果队列为空,退出循环,否则回到2
    然后这个spfa算法快就快在一个点的最短距离可能当这个点在队列中的时候就被更新多次,达到节省时间的效果。
    也正是针对这个特性,有了很多spfa的hack
    也有了很多spfa的优化(主要就是反hack)
    所以网上是有spfa已死的说法。。。我个人不发表意见。
    优化的事情后面再说,先给代码
    实现这个没什么难度。。。我闭着眼睛都能打出来
void spfa(int s)
{
  std::memset(dis,0f7f,sizeof(dis));
  std::memset(vis,0,sizeof(vis));
  queue<int> q;
  q.push(s);
  vis[s]=true;
  dis[s]=0;
  while(!q.empty())
  {
    int i=q.front(); q.pop(); vis[i]=false;
    for(int j=head[i];j;j=edge[j].v) // 这里存边从1开始
    {
      if(dis[edge[j].v]<dis[i]+edge[j].w)
      {
        dis[edge[j].v]=dis[i]+edge[j].w;
        if(!vis[edge[j].v]) q.push(edge[j].v), vis[i]=true;
      }
    }
  }
}

关于优化
主流的优化有SLF,LLL,一些偏门的优化比如NTR之类的。。。理论上是可以的(因为我觉得他挺有道理)
但是由于实测和篇幅的原因此处略过。
主要就讲SLF和LLL
SLF就是small label first,对于将要入队的点\(i\),如果\(dis_i<dis_{q.front()}\)那么就把\(i\)扔进队头,否则仍入队尾。这里需要双端队列deque来实现
SLF带容错:我们知道SLF太硬性了,如果队头是一个最小的元素占着那多不好,所以我们优化一下,如果\(dis_i<dis_{q.front()}+W\)就扔进队头,这里\(W\)是常数,一般取\(W=\sqrt[2]{\sum w_i}\)
LLL就是large label lastest(是吧。。。我不知道)意思就是大的放后面。我们用两个变量\(sum,tot\)来记录当前队列中\(sum=\sum_{\forall i,vis[i]=true}dis[i],tot\)表示队列元素的数量,这样我们就可以用过\(sum/tot\)知道队列中\(dis\)的平均值。
我们要取用一个元素的时候,我们先看看这个元素\(dis_i\),如果这个东西大于平均值那么不看他,把他放到队尾,取用下一个队头。重复操作直到满足队头元素的\(dis\)小于平均值。
注意每次队列变动(取出,压入)都需要更新平均值
swap:每次队列变动的时候检查一下队头队尾。如果\(dis_{q.front()}>dis_{q.back()}\)就把队头和队尾的元素交换一下

然后上述优化是可以同时用的。。。
所以经过亲测
我只想说

SLF NB

*P5905 【模板】Johnson 全源最短路

上面你都看见了嘛。。。
:看见了,打星号表示不用学
。。。这确实。。。
但是不是那个
你看见我n轮spfa打满优化,都过不了那题。。。
怎么办呢?出来了一个Johnson多源最短路算法
其实我们发现很多最短路的hack都是在spfa或者说Bellman-ford上提出的。
这就说明其实dij的时间复杂度很稳定
(不信你把spfa时间复杂度中的常数计算一下?由于你的实现方法这个数会变得很薛定谔)
其实也很好理解。这里Johnson其实就是跑了n次的dij
但是dij不是不能处理负边权的吗?
是啊所以才叫Johnson不叫dij
Johnson的思路就是先把边权重新赋值,让他们所有都为正,又不影响最短路的一种方法。
首先我们定义超级源点\(0\),所有的点都和\(0\)有一条权值为\(0\)的边。
然后以0为起点跑一次Bellman-ford(对,我看见他很不爽。。。但是只跑一次忍了吧。。。)
把这次的最短路结果保存到数组\(h\)中,然后定义\(h_i\)表示点\(i\)的高度(势能?不管了随便吧)
然后对于原图中从\(u\)连向\(v\)的权值为\(w\)的边我们把它的权值重赋值为\(w+h_u-h_v\)
这个重赋值后一定是非负数。
我们分类讨论一下。
如果\(s\)\(v\)的最短路是通过\(w\)的,那么这条边的权值就是0
否则,就会有\(h_v-h_u<w\)(因为肯定绕其他地方更好,所以这条边就应该大于这个的差)
所以就会有这个东西非负
然后非负了
就跑n轮dij
正确性证明:
如果用\(w_{u,v}\)表示从\(u\)连向\(v\)的边的权值
如果原来的从\(s\)\(t\)的最短路长度是\(w_{s,p_1}+w_{p_1,p_2}+\dots+w_{p_k,t}\)
我们可以发现它和下面这个式子是相等的

\[(w_{s,p_1}+h_s-h_{p_1})+(w_{p_1,p_2}+h_{p_1}-h_{p_2})+\dots+(w_{p_k,t}+h{p_t}-h_t)-h_s+h_t \]

也就是重赋值后的表达式,然后再减去一个\(h_s-h_t\),这是一个常数,无论你怎么走最短路,这个两个点的相对高度差都是不会变的
所以我们对边进行重赋值后求出的最短路只是比原图中的最短路多了一个常数,我们最后把它减掉就可以了
这样就实现了n轮dij求出多源最短路

#include <cstdio>
#include <cmath>
#include <queue>
#include <cstring>

const long long N=3e3, M=6e3,inf=-1,oinf=1e9;
long long cnt[N+1];
long long ans[N+1];
long long dis[N+1],h[N+1];
bool vis[N+1];

struct Edge
{
	long long u,v;
	long long w;
}edge[M+N+1];
long long head[N+1];
void add(long long a,long long b,long long c)
{
	static long long tot=0;
	edge[++tot]=(Edge){head[a],b,c}; head[a]=tot;
}

long long n,m,a,b;
long long c;

void print(long long i)
{
	long long res=0;
	for(long long j=1;j<=n;++j)
	{
		dis[j]-=h[i]-h[j];
		if(dis[j]>(long long)oinf) dis[j]=oinf;
		res+=j*dis[j];
	}
	ans[i]=res;
	return;
}

bool Bellman(long long s,const long long& n)
{
	std::memset(dis,0x7f,sizeof(dis));
	std::memset(vis,0,sizeof(vis));
	std::memset(ans,0,sizeof(ans));
	std::memset(cnt,0,sizeof(cnt));
	std::memset(h,0,sizeof(h));
	std::queue<long long > q;
	q.push(s);
	dis[s]=0;
	vis[s]=true;
	while(!q.empty())
	{
		long long i=q.front(); q.pop(); vis[i]=false;
		for(long long j=head[i];j;j=edge[j].u)
		{
			if(dis[edge[j].v]>dis[i]+edge[j].w)
			{
				if(cnt[edge[j].v]>n) return true;
				dis[edge[j].v]=dis[i]+edge[j].w;
				if(!vis[edge[j].v])
				{
					++cnt[edge[j].v];
					if(cnt[edge[j].v]>n) return true;
					q.push(edge[j].v);
					vis[edge[j].v]=true;
				}
			}
		}
	}
	memcpy(h,dis,sizeof(h));
	return false;
}

void Johnson(long long s)
{
	std::memset(dis,0x7f,sizeof(dis));
	std::memset(vis,0,sizeof(vis));
	std::priority_queue<std::pair<long long,long long>,std::vector<std::pair<long long,long long> >,std::greater<std::pair<long long,long long> > > q;
	q.push(std::make_pair(0,s));
	dis[s]=0;
	while(!q.empty())
	{
		long long i=q.top().second; q.pop(); if(vis[i]) continue;
		vis[i]=true;
		for(long long j=head[i];j;j=edge[j].u)
		{
			if(dis[edge[j].v]>dis[i]+edge[j].w)
			{
				dis[edge[j].v]=dis[i]+edge[j].w;
				q.push(std::make_pair(dis[edge[j].v],edge[j].v));
			}
		}
	}
}

inline void Jinit()
{
	for(long long i=1;i<=n;++i)
	{
		for(long long j=head[i];j;j=edge[j].u) edge[j].w+=h[i]-h[edge[j].v];
	}
	return;
}

int main()
{
	scanf("%lld%lld",&n,&m);
	for(long long i=1;i<=m;++i)
	{
		scanf("%lld%lld%lld",&a,&b,&c);
		add(a,b,c);
	}
	for(long long i=1;i<=n;++i) add(0,i,0);
	if(Bellman(0,n+1))
	{
		printf("-1\n");
		return 0;
	}
	Jinit();
	for(long long i=1;i<=n;++i)
	{
		Johnson(i);
		print(i);
	}
	for(long long i=1;i<=n;++i) printf("%lld\n",ans[i]);
	return 0;
}
posted @ 2021-11-24 16:36  IdanSuce  阅读(236)  评论(0编辑  收藏  举报