拓扑排序的几种方法

本文详细介绍了拓扑排序的概念,包括有向无环图(DAG)的充要条件,以及两种典型的拓扑排序算法:Kahn算法和基于DFS的拓扑排序。Kahn算法通过维护入度为0的顶点集合进行排序,而DFS实现则利用栈结构记录排序结果。两种算法的时间复杂度均为O(E+V)。文章还讨论了如何在特定条件下满足输出顶点从小到大的要求。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

先从一到HDoj的题目入手

HDOJ 1285 AC代码

这里只需要记录入度和顶点的联通性质就可以了。
三重循环算法:
1.第一层循环:代表每次排出一个点来
2.第二层循环:寻找入度为0的一个点
3 第三层循环:寻找入度为0的点相联通的点,它们的入度减1
O(n^3) 复杂度高的原因:用邻接矩阵,想找相邻顶点是O(N),如果是邻接表就是O(1)
<pre name="code" class="cpp">#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=520;
bool connect[maxn][maxn];
int in[maxn];
int N,M;

void topsort(){
	for(int i=1;i<=N;i++){
		for(int j=1;j<=N;j++){
			if(in[j]==0){
				in[j]=-1;
				printf("%d",j);
				if(i!=N) printf(" ");
				for(int k=1;k<=N;k++){
					if(connect[j][k]){
						in[k]--;
					}
				}
				break;
			}
		}
	}
	printf("\n");
}
int main(int argc, char const *argv[])
{
	while(cin>>N>>M){
		memset(connect,false,sizeof(connect));
		memset(in,0,sizeof(in));
		int a,b;
		for(int i=0;i<M;i++){
			scanf("%d %d",&a,&b);
			if(!connect[a][b]){
				connect[a][b]=true;
				in[b]++;
			}
		}
		topsort();
	}
	return 0;
}


 

一些小小的优化:

使用队列

1.检测入度为0的点,加入队列

2.while(队列非空) 

  弹出队首元素,检测与他相邻的点,入度减一,如果入度为0,加入队列。

3.当队列为空的时候,检测队列弹出元素是否是n个,否则说明不是DAG

拓扑排序的一些定义和算法

 一个有向图能被拓扑排序的充要条件就是它是一个有向无环图(DAGDirected Acyclic Graph)

拓展到拓扑排序中,结果具有唯一性的条件也是其所有顶点之间都具有全序关系。如果没有这一层全序关系,那么拓扑排序的结果也就不是唯一的了。在后面会谈到,如果拓扑排序的结果唯一,那么该拓扑排序的结果同时也代表了一条哈密顿路径。

典型实现算法:

Kahn算法 :

关键在于需要维护一个入度为0的顶点的集合:

每次从该集合中取出(入度为0)的一个顶点,将该顶点放入保存结果的List中。

紧接着循环遍历由该顶点引出的所有边,从图中移除这条边,同时获取该边的另外一个顶点,如果该顶点的入度在减去本条边之后为0(也变成了叶子节点),那么也将这个顶点放到入度为0的集合中。然后继续从集合中取出一个顶点…………

 

当集合为空之后,检查图中是否还存在任何边,如果存在的话,说明图中至少存在一条环路。不存在的话则返回结果List,此List中的顺序就是对图进行拓扑排序的结果。

复杂度分析:

初始化入度为0的集合需要遍历整张图,检查每个节点和每条边,因此复杂度为O(E+V);

然后对该集合进行操作,又需要遍历整张图中的,每条边,复杂度也为O(E+V);

因此Kahn算法的复杂度即为O(E+V)


基于DFS的拓扑排序:

除了使用上面直观的Kahn算法之外,还能够借助深度优先遍历来实现拓扑排序。这个时候需要使用到栈结构来记录拓扑排序的结果。

同样摘录一段维基百科上的伪码:

L ← Empty list that will contain the sorted nodesS ← Set of all nodes with no outgoing edgesfor each node n in S do    visit(n) function visit(node n)    if n has not been visited yet then        mark n as visited        for each node m with an edgefrom m to ndo            visit(m)        add n to L

DFS的实现更加简单直观,使用递归实现。利用DFS实现拓扑排序,实际上只需要添加一行代码,即上面伪码中的最后一行:add n to L

需要注意的是,将顶点添加到结果List中的时机是在visit方法即将退出之时。

这个算法的实现非常简单,但是要理解的话就相对复杂一点。

关键在于为什么在visit方法的最后将该顶点添加到一个集合中,就能保证这个集合就是拓扑排序的结果呢?

因为添加顶点到集合中的时机是在dfs方法即将退出之时,而dfs方法本身是个递归方法,只要当前顶点还存在边指向其它任何顶点,它就会递归调用dfs方法,而不会退出。因此,退出dfs方法,意味着当前顶点没有指向其它顶点的边了,即当前顶点是一条路径上的最后一个顶点。

 

复杂度分析:

复杂度同DFS一致,即O(E+V)。具体而言,首先需要保证图是有向无环图,判断图是DAG可以使用基于DFS的算法,复杂度为O(E+V),而后面的拓扑排序也是依赖于DFS,复杂度为O(E+V)

 

两种实现算法的总结:

这两种算法分别使用链表和栈来表示结果集。

对于基于DFS的算法,加入结果集的条件是:顶点的出度为0。这个条件和Kahn算法中入度为0的顶点集合似乎有着异曲同工之妙,这两种算法的思想犹如一枚硬币的两面,看似矛盾,实则不然。一个是从入度的角度来构造结果集,另一个则是从出度的角度来构造。

 

实现上的一些不同之处:

Kahn算法不需要检测图为DAG,如果图为DAG,那么在出度为0的集合为空之后,图中还存在没有被移除的边,这就说明了图中存在环路。而基于DFS的算法需要首先确定图为DAG,当然也能够做出适当调整,让环路的检测和拓扑排序同时进行,毕竟环路检测也能够在DFS的基础上进行。

二者的复杂度均为O(V+E)

 

一些启发:

DFS,heap和queue能否解决上题?

1.DFS 从某个顶点开始深搜,到尾部就把它输出来(这种如果从小到大循环,也可以控制标号);2.构造堆,先从小到大扫一遍,入度为0的入堆。然后考察和这个顶点相邻的顶点,如果相减1个后入度为0,就把它入堆。直到堆为0

怎么判断有环?如果堆空,但是弹出节点不到n个,就是空(队列为空说明没有入度为0的点,说明有环)
现在发现这个算法是错的,因为这个算法保证的是:对于入度为0的点,先输出标号小的,并不能保证拓扑排序的正确性,还是要用队列

然而用queue,代码如下。(纳入queue的时候,是根据叶子节点扫一遍,这和题目要求不完全符合比如题目中的样例就不满足。题目要求输出顶点从小到大,这种特殊的要求其实要不断扫描叶子节点中入度为0的点,所以上文的实现要O(N^3))如果用下文中代码,那么是一种bfs,按照每一层的叶子节点排序后加入队列,无法实现题目中的要求。

队列邻接矩阵的实现复杂度是O(n^2)
<pre name="code" class="cpp">#include <iostream>
#include <algorithm>
#include <queue>
#include <cstdio>
using namespace std;
const int maxn=520;
queue<int> List;
bool connect[maxn][maxn];
int in[maxn];
int N,M;
int pending[maxn];
int main(){
	while(cin>>N>>M){
		int a,b;
		memset(connect,false,sizeof(connect));
		memset(in,0,sizeof(in));
		for(int i=0;i<M;i++){
			cin>>a>>b;
			connect[a][b]=true;
			in[b]++;
		}
		for(int i=1;i<=N;i++){
			if(in[i]==0){
				in[i]=-1;
				List.push(i);
			}
		}
		int NumOfOut=0;
		while(!List.empty()){
			NumOfOut++;
			int out=List.front();
			List.pop();
			int NumOfPending=0;
			for(int j=1;j<=N;j++){
				if(in[j]!=-1 && connect[out][j]){
					in[j]--;
					if(in[j]==0){
						in[j]=-1;
						pending[NumOfPending++]=j;
					}
				}
			}
			sort(pending,pending+NumOfPending);
			for(int i=0;i<NumOfPending;i++){
				List.push(pending[i]);
			}
			if(NumOfOut==N) printf("%d\n",out);
			else printf("%d ",out);
		}

	}
	return 0;
}


 

图的邻接表表示:用vector数组来表示邻接表

需要清空可以用赋值的办法,也可以用clear方法,clear方法并不是释放内存(对于题目,可以减少循环使用的时候打开内存的开销)
关于clear:
Clear content
Removes all elements from the vector (which are destroyed), leaving the container with a size of 0.A reallocation is not guaranteed to happen, and the vector capacity is not guaranteed to change due to calling this function. A typical alternative that forces a reallocation is to use swap:
 
vector<T>().swap(x);   // clear x reallocating 


DFS写法:

<span style="color:#ff0000;"></span><pre name="code" class="cpp">#include <iostream>
#include <vector>
#include <cstdio>	
using namespace std;
const int maxn=1005;
vector<int> Graph[maxn];
int used[maxn];
int list[maxn];
int NumOfOut;
int N,M;
bool dfs(int k){//特别注意:used不是只有1和0两种方式就够了
	//我们需要区分:已经被拓扑排序了的点和正在拓扑排序中的点
	//正在拓扑排序中的点出现了两次,才是环
	//用0代表未操作,1代表正在排序,2代表已经排完
	//我们的排序是倒序的
	used[k]=1;
	for(int i=0;i<Graph[k].size();i++){
		if(used[Graph[k][i]]==1){
			return false;//loop
		}
		else if(used[Graph[k][i]]==0)
		dfs(Graph[k][i]);
	}
	list[NumOfOut++]=k;
	used[k]=2;
	return true;
}
int main(){
	while(cin>>N>>M){
		NumOfOut=0;
		memset(used,0,sizeof(used));
		for(int i=0;i<maxn;i++) Graph[i].clear();
			
		int a,b;
		for(int i=0;i<=M;i++){
			scanf("%d %d",&a,&b);
			Graph[a].push_back(b);
		}
		for(int i=1;i<=N;i++){
			if(used[i]==0){
				if(!dfs(i))
					cout<<"have loop"<<endl;
			}
		}//不能从一个起点开始dfs就算了,因为这个点可能没有出度,这样不能完全扫描
		for(int i=0;i<NumOfOut;i++)
			cout<<list[i]<<" ";
		cout<<endl;
	}

	return 0;
}

 
 

拓扑排序的功能

拓扑排序可以判断一个图是否有环。
(注意:遍历每个顶点的邻接表只能判断是否有自环,不能判断是否有其他环。)
对于拓扑排序中的检测方法之所以是可行的,因为这个方法深搜了所有可能的相邻顶点( 注意一个顶点被弹出只有他的所有相邻顶点都被访问了而且没有自环)

拓扑排序还有一个重要的功能就是判断节点是一条链,还是在某个节点出现了分叉,这个很好理解,就用上面的非递归代码就可以判断,每次找入度为0的节点数目只能有1个,如果出现了两个则说明在该节点的父亲节点出现了分叉。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值