图论小专题A

大意失荆州。今天考试一到可以用Dijkstra水过的题目我竟然没有做出来,这说明基础还是相当重要。考虑到我连Tarjan算法都不太记得了,我决定再过一遍蓝皮书,对图论做一个小的总结。图论这个部分可能会分几次发布出来。

0 图的储存

一般而言,在代码中我们会用\(M\)表示边的个数,\(N\)表示点的个数。

  • 邻接矩阵:用一个矩阵\(A_{ij}\)表示点\(i,j\)的距离或连通性。
  • 邻接表:用vector类型edge储存。其中edge[i][j]表示以\(i\)为起点的第\(j\)条边。
  • 链式前向星:非常重要的一种储存方式。是一种类似“哈希表”的结构,其中用head记录每个点对应的边链表的表头,每个边记录边链表的下一条边。

无向边可以用两条方向相反的有向边代替。

1 最短路

1.1 单源最短路问题(SSSP,Single Source Shortest Path)

对于任意一个点\(i\),求其到某一个固定点的距离\(dis_i\)

在求解最短路问题中,有一个核心的“松弛操作relax”:

\[ d(i,j) =\min\{d(i,j), d(i,k)+d(k,j)\} \]

它是所有SSSP算法的基础。
如果有\(d(i,j)>d(i,k)+d(k,j)\),我们就说\(k\)能松弛\((i,j)\)

Dijkstra算法
贪心算法。其使用条件是没有负边。
这里采用了一个贪心的策略:注意到全局最优的SP是不可能再被松弛的,否则就跟这个性质不符了。我们每次就用这个最优的SP去松弛其他的SP,从而使得每一个SP都逐步变得最优,即不可松弛。

这个算法的代码如下:

inline void dijkstra()
{
	memset(dis, 0x3f, sizeof(dis));
	memset(used, 0, sizeof(used));
	
	dis[S] = 0;
	
	while(1)
	{
		int transferNode = getTransferNode();
		if(transferNode == -1)
			return;
		
		for(rg int e = head[transferNode]; e; e = edge[e].next)
		{
			int to = edge[e].to;
			checkMin(dis[to], dis[transferNode] + edge[e].len);//用最小值更新dis[to]
		}
		
		used[transferNode] = true;
	}
}

其中getTransferNode()是找到dis最小的中转点。

inline int getTransferNode()
{
	int transferNode = -1;
	number disMin = Inf;//number 是typedef过的long long 
	for(rg int i = 1; i <= N; ++ i)
	{
		if(!used[i] && dis[i] < disMin)
		{
			disMin = dis[i]; 
			transferNode = i;
		}
	}
	return transferNode;
}

由于要枚举中转点和每个与中转点相连的点,上面代码的时间复杂度是\(O(N^2)\)的。
当然,我们也可以用二叉堆(priority_queue)来维护\(dis\)数组。这样可以在\(O(N\log N)\)的时间内完成算法。上述getTransferNode部分代码如下:

priority_queue< pair<number, int> > heap;
inline int getTransferNode()
{
	int transferNode = -1;
	while(heap.size() > 0)
	{
		transferNode = heap.top().second;
		heap.pop();
		if(used[transferNode])
			continue;
		used[transferNode] = true;
		return transferNode;
	}
	return -1;
}

主过程代码如下:

inline void dijkstra()
{
	memset(dis, 0x3f, sizeof(dis));
	memset(used, 0, sizeof(used));
	
	dis[S] = 0;
	heap.push(make_pair(0, S));
	
	while(1)
	{
		int transferNode = getTransferNode();
		if(transferNode == -1)
			return;
		
		for(rg int e = head[transferNode]; e; e = edge[e].next)
		{
			int to = edge[e].to;
			if(dis[to] > dis[transferNode] + edge[e].len)
			{
				dis[to] = dis[transferNode] + edge[e].len;
				heap.push(make_pair(-dis[to], to));
			}
		}
		
		used[transferNode] = true;
	}
}

Bellman-Ford和SPFA
由于负边权会导致\(dis_i>dis_j+d(j,i)\),最小的\(dis\)反而可能会更新出一个比它更小的\(dis\)。这样,一个点可能会不断被更新,这个全局最优的贪心策略就不成立了。
在之前的Dijkstra算法上,我们再考虑如何进一步优化。
如果对于任意的边\((i,j)\),均有\(dis_i\leq dis_j+d(i,j)\),那么这个\(dis\)就是所求的答案。这个不等式叫做三角形不等式。因此,我们可以考虑这样一种做法:我们由“点松弛”变为“边松弛”,使得每条边满足三角型不等式。
Bellman-Ford的算法流程大致就是:不断地扫描并更新每一条边,使得每一条边都满足三角形不等式,直到没有边可以再更新。

由于Bellman-Ford算法是固定的\(O(NM)\),在实际运行上速度有时不如Dijkstra,这里就不张贴这个代码了。我们考虑队列优化版的Bellman-Ford算法:SPFA(Shortest Path Fast Algorithm)算法。这个算法的命名其实和“快速排序”这个名字一样一根筋。正因如此,SPFA这个名字只在中国流行。

仔细想想,根据Dijkstra算法的推论,是不是只有全局最优值才能更新其他值?我们可以类似地,每次只对刚更新完的\(dis\)点所相连的边进行松弛。我们可以考虑建立一个队列,当一个节点被更新之后,就将其加入队列,并在之后考虑它所相连的边是否能被更新。

由于每一次入队和出队都代表一次边的更新,而一个节点可能入队出队若干次,故算法的时间复杂度下限为\(\Omega(M)\),上限为\(O(MN)\)
算法的代码如下:

queue<int> q;
bool state[maxN];
#define IN_QUEUE true
#define NOT_IN_QUEUE false
inline void SPFA()
{
	memset(dis, 0x3f, sizeof(dis));
	memset(state, NOT_IN_QUEUE, sizeof(state));
	
	dis[S] = 0; state[S] = IN_QUEUE; q.push(S);
	while(!q.empty())
	{
		int cur = q.front(); q.pop();
		state[cur] = NOT_IN_QUEUE;
		for(rg int e = head[cur]; e; e = edge[e].next)
		{
			int to = edge[e].to;
			if(dis[to] > dis[cur] + edge[e].len)
			{
				dis[to] = dis[cur] + edge[e].len;
				if(state[to] == NOT_IN_QUEUE)
				{
					state[to] = IN_QUEUE;
					q.push(to);
				}
			}
		}
	}
}
#undef IN_QUEUE
#undef NOT_IN_QUEUE

SPFA算法接近上界的条件是图是疏密图或网格图。如果图中没有负边权,我们可以用优先队列队优化这个算法。此时算法时间复杂度是比较稳定的\(O(M\log N)\),但是往往就不如Dijkstra优了。
在实际应用的过程中,要根据实际条件判断如何使用。图中往往有样例数据、数据范围、子任务等要素。可以通过这些来判断图是否稀疏,从而分门别类地使用对应算法。

SPFA算法的一大优势在于可以判断是否有负环。根据\(O(NM)\)的时间复杂度上限可以知道,如果一个点被更新大于等于\(n\)次,说明这个图一定存在负环。此时存在一条无穷小的路径,SSSP也就没有意义了。

1.2 最短路衍生算法

SPFA的过程也可以用dfs实现。但是显然的,由于SPFA往往需要对若干个点更新多次,如果使用dfs,不仅可能会超时,还有可能因迭代过多而发生栈溢出。但是,dfs的SPFA有一个非常明显的长处:找负环。
和bfs的做法不同,我们将队列改为栈,每次将当前节点入栈,搜索完后将当前节点出栈。只有可以被松弛的节点才有搜索的必要。
代码很简单,如下:

queue<int> q;
bool state[maxN];
#define IN_STACK true
#define NOT_IN_STACK false
inline bool SPFA(int curNode)
{
	state[curNode] = IN_STACK;
	for(rg int e = head[curNode]; e; e = edge[e].next)
	{
		int to = edge[e].to;
		if(dis[to] > dis[curNode] + edge[e].len)
		{
			dis[to] = dis[curNode] + edge[e].len;
				if(state[to] == IN_STACK || SPFA(to) == true)
					return true;
		}
	}
	state[curNode] = NOT_IN_STACK;
	return false;
}

再主程序中对每一个节点进行一次SPFA,直到找到负环为止。

对于随机图,我们可以先运行一个SPFA_INIT:首先选定一个初始点,每次扩展到一个节点时,随机地选择一条可松弛的边进行下一步扩展;如果不存在可松弛边,就结束这一过程。
对每个点进行一次SPFA_INIT,然后再用SPFA对每个点进行扩展,这样效率会有所提高。

更近一步的,我们还可以使用一个IDFS_SPFA,即加深迭代搜索。每次选取搜索的深度分别为\(1,2,4,8,16,\cdots\)是一个不错的选择。这些过程都比较简单,就是在原代码上进行简单的改装。代码就不再粘贴。

1.3 多源最短路径算法(MSSP)

Floyd算法

非常经典的算法。其实质是动态规划算法。

通过类比,我们发现松弛操作\(d(i,j)=\min\{d(i,j),d(i,k)+d(k,j)\}\)与区间DP十分类似。这意味着\(i\)\(j\)两个状态变量不适合用来划分阶段。

我们设\(F(k,i,j)\)表示以\(1,2,\cdots,k\)点作为中转点,\(i,j\)之间的最短距离。我们可以在选前\(k-1\)个点的基础上,选择第\(k\)个点,并看其是否更优。有方程:

\[ F(k,i,j)=\min\{F(k - 1,i,j), F(k - 1,i,k)+F(k - 1,k,j)\} \]

注意这个方程满足两个性质:

  • 转移\(F(k,i,j)=F(k-1,i,j)\)成立。
  • \(k\)阶段刚计算出的状态量\((i,j)\)不会被再次引用。
    由此,我们可以删去\(k\)维,得到状态转移方程:

\[ F(i,j)=\min\{F(i,j), F(i,k)+F(k,j)\} \]

注意这里\(k\)是阶段,要放在最外层循环。最后可以算出任意两点的最短路。
\(N\)个阶段,每个阶段\(N^2\)个状态,所以转移的时间复杂度为\(O(N^3)\)

Floyd算法还可以判断点的连通性:只要把状态表示成\(F(k,i,j)\)表示成“以前\(k\)个点为中转点,能否让\(i,j\)连通”。转移方程同理进行对应修改即可。这个问题和“传递闭包”有关,“闭包”的概念可以在网络上或者离散数学教材上找到,并不是一个很高深的概念。

Dijkstra算法?
从理论上来讲,可以用\(dis_{i,j}\)表示以\(i\)为源点,\(j\)到源点的最短路,然后做\(N\)次Dijkstra算法。如果采用二叉堆优化,理论上可以用\(O(N^2\log N)\)的时间完成处理。但是至于为什么在网络上并没有有关介绍,是常数原因还是其他?我本人也不清楚。如果有哪位大佬知道原因或者做过有关测试,欢迎在下方留言。

posted @ 2019-08-06 15:05  LinearODE  阅读(262)  评论(0编辑  收藏  举报