• 图论小专题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(Nlog 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_ileq 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(Mlog 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^2log N))的时间完成处理。但是至于为什么在网络上并没有有关介绍,是常数原因还是其他?我本人也不清楚。如果有哪位大佬知道原因或者做过有关测试,欢迎在下方留言。

  • 相关阅读:
    1135
    Sort(hdu5884)
    🍖CBV加装饰器与正反向代理简介
    🍖django之Cookie、Session、Token
    🍖Django之forms组件
    🍖forms组件源码分析
    🍖Django与Ajax
    🍖Django框架之模板层
    进程的内存空间相互隔离
    Codeforces Round #583 (Div. 1 + Div. 2, based on Olympiad of Metropolises), problem: (D) Treasure Island
  • 原文地址:https://www.cnblogs.com/LinearODE/p/11309029.html
Copyright © 2020-2023  润新知