先从一到HDoj的题目入手
HDOJ 1285 AC代码
<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
拓扑排序的一些定义和算法
一个有向图能被拓扑排序的充要条件就是它是一个有向无环图(DAG:Directed 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
然而用queue,代码如下。(纳入queue的时候,是根据叶子节点扫一遍,这和题目要求不完全符合比如题目中的样例就不满足。题目要求输出顶点从小到大,这种特殊的要求其实要不断扫描叶子节点中入度为0的点,所以上文的实现要O(N^3))如果用下文中代码,那么是一种bfs,按照每一层的叶子节点排序后加入队列,无法实现题目中的要求。
<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数组来表示邻接表
| |
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个,如果出现了两个则说明在该节点的父亲节点出现了分叉。