拓扑排序的概念
定义:将有向图中的顶点以线性方式进行排序。即对于任何连接自顶点u到顶点v的有向边u->v,在最后的排序结果中,顶点u总是在顶点v的前面。
考虑一个经典例子——选课。假设我非常想学习一门机器学习的课程,但是在修这门课程之前,我们必须要先学习一些基础课程,比如计算机科学概论,C语言程序设计,数据结构,算法等等。那么这个制定选修课程顺序的过程,实际上就是一个拓扑排序的过程。每门课程相当于有向图中的一个顶点,而连接顶点之间的有向边就是课程学习的先后关系。
拓扑排序的充要条件
是不是所有的有向图都能被拓扑排序呢?显然不是。
继续考虑上面的例子,如果告诉你在选修《计算机科学概论》这门课之前需要你先学习《机器学习》,你是不是会被弄糊涂?在这种情况下,就无法进行拓扑排序,因为它中间存在互相依赖的关系,从而无法确定谁先谁后。在有向图中,这种情况被描述为存在环路。
因此,一个有向图能被拓扑排序的充要条件就是它是一个有向无环图。
偏序和全序的关系
还是以上面选课的例子来描述这两个概念。假设我们在学习完了《算法》这门课后,可以选修《机器学习》或者《计算机图形学》。这也就意味着,学习《机器学习》和《计算机图形学》这两门课之间没有特定的先后顺序。因此,在我们所有可以选择的课程中,任意两门课程之间的关系要么是确定的(即拥有先后关系),要么是不确定的(即没有先后关系),绝对不存在互相矛盾的关系(即环路)。
以上就是偏序的意义,抽象而言,有向图中两个顶点之间不存在环路,至于连通与否,是无所谓的。所以,有向无环图必然是满足偏序关系的。
理解了偏序的概念,那么全序就好办了。所谓全序,就是在偏序的基础上,有向无环图中的任意一对顶点还需要有明确的关系(反映在图中,就是单向连通的关系,注意不能双向连通,那就成环了)。可见,全序就是偏序的一种特殊情况。
回到我们的选课例子中,如果《机器学习》需要在学习了《计算机图形学》之后才能学习,那么它们之间也就存在了确定的先后顺序,原本的偏序关系就变成了全序关系。
实际上,很多地方都存在偏序和全序的概念。
比如对若干互不相等的整数进行排序,最后总是能够得到唯一的排序结果。我们以偏序/全序的角度来考虑一下这个再自然不过的问题,可能就会有别的体会了。
如何用偏序/全序来解释排序结果的唯一性呢?
我们知道不同整数之间的大小关系是确定的,比如1总是小于4的。这就是说,这个序列是满足全序关系的。而对于拥有全序关系的结构(如拥有不同整数的数组),在其线性化(排序)之后的结果必然是唯一的。
对于排序的算法,我们评价指标之一是看该排序算法是否稳定,即值相同的元素的排序结果是否和出现的顺序一致。比如,我们说快速排序是不稳定的,因为最后的快排结果中,相同元素的出现顺序和排序前不一致了。如果用偏序的概念可以这样解释这一现象:相同值的元素之间的关系是无法确定的。因此它们在最终的结果中的出现顺序可以是任意的。而对于诸如插入排序这种稳定性排序,它们对于值相同的元素,还有一个潜在的比较方式,即比较它们的出现顺序,出现靠前的元素大于出现后出现的元素。因此通过这一潜在的比较,将偏序关系转换为了全序关系,从而保证了结果的唯一性。
拓展到拓扑排序中,结果具有唯一性的条件也是其所有顶点之间都具有全序关系。如果没有这一层全序关系,那么拓扑排序的结果也就不是唯一的了。如果拓扑排序的结果唯一,那么该拓扑排序的结果同时也代表了一条哈密顿路径。
入度和出度
入度和出度是图论算法中重要的概念之一。
入度(in-degree)通常指某点作为有向边终点的次数,即有多少条边指向该点。
出度 (out-degree) 是指从该点出发的有向边的数目。
拓扑排序的实现
拓扑排序算法主要是循环执行以下两步,直到不存在入度为0的顶点为止。
- 选择一个入度为0的顶点并输出之;
- 从网中删除此顶点及所有出边(以它为起点的有向边)。
直至图空,或者图不空但找不到入度为0的顶点为止。
循环结束后,若输出的顶点数小于图中的总顶点数,说明图中存在回路;否则图中不存在回路,输出的顶点序列就是一种拓扑序列。
我们继续以题来进行进一步讲解:
Description
有N个比赛队(1<=N<=500),编号依次为1,2,3,。。。。,N进行比赛,比赛结束后,裁判委员会要将所有参赛队伍从前往后依次排名,但现在裁判委员会不能直接获得每个队的比赛成绩,只知道每场比赛的结果,即P1赢P2,用P1,P2表示,排名时P1在P2之前。现在请你编程序确定排名。
输入
第一行是2个数N(1<=N<=500)和M;其中N表示队伍的个数,M表示接着有M行的输入数据。接下来的M行数据中,每行也有两个整数P1,P2,表示即P1队赢了P2队。
输出
给出一个符合要求的排名。输出时队伍号之间有空格,最后一名后面没有空格。
其他说明:符合条件的排名可能不是唯一的,此时要求输出时编号小的队伍在前;输入数据保证是正确的,即输入数据确保一定能有一个符合要求的排名。
Sample Input
4 3
1 2
2 3
4 3
Sample Output
1 2 4 3
完整代码实现:
采用邻接矩阵实现,map[i][j]=0
,表示节点i和j没有关联;map[i][j]=1
,表示存在边<i,j>
,并且是从i指向j。
#include <iostream>
#include <vector>
#include <string>
#include<stack>
#include<queue>
#include<set>
#include<map>
#include <numeric>
#include<limits.h>
#include <algorithm>
using namespace std;
int main() {
int t;
cin>>t;
while(t--) {
int n,m;//课程总数和顺序关系的数量
cin>>n>>m;
int i,j;
vector<int> in(n+1,0);//记录每个节点的入度
vector<int> book(n+1,0);//记录该节点是否已经被确定
vector<vector<int>> map(n+1,vector<int>(n+1,0));//记录两个节点的顺序关系
int from,to;
for(i=1;i<=m;i++) {//初始化每个节点的入度
cin>>from>>to;
map[from][to]=1;//表示存在节点from到结点to的顺序关系
in[to]++;
}
while(1) {
int index=0;//当前这轮确定了的节点编号
bool valid = 1;
for(i=1;i<=n;i++)
if(book[i]==0) {
valid = 0;
break;
}
if(valid) {//如果所有节点都已经访问,说明该组数据是合法的,直接输出
cout<<"Correct"<<endl;
break;
}
bool find = false;
for(i=1;i<=n;i++) {
if(book[i]==0 && in[i]==0) {//若某个未确定节点的入度为0
index=i;
book[i]=1;
find = true;
break;
}
}
if(!find) {//如果在未确定的节点中,找不到入度为0的
cout<<"Wrong"<<endl;
break;
}
for(i=1;i<=n;i++)
if(book[i]==0 && map[index][i]==1) //若某个节点未被确定,而且节点index是它的前置节点,就将其入度减一
in[i]--;
}
}
return 0;
}
如果采用邻接表存储边的信息,那么可以写成:
int in[100001];//记录每个节点的入度
vector<int> edge[100001]; //邻接表
int main() {
int t;
cin>>t;
while(t--) {
int n,m;//课程总数和顺序关系的数量
int from,to;
int i,k;
int index;
int cnt=0;
cin>>n>>m;
for(i=1;i<=n;i++) {//初始化
in[i]=0;
edge[i].clear();
}
for(i=1;i<=m;i++) {//初始化每个节点的入度
cin>>from>>to;
edge[from].push_back(to);
in[to]++;
}
for(k=1;k<=n;k++) {
index=0;//当前这轮确定了的节点编号
bool valid = false;
for(i=1;i<=n;i++) {
if(in[i]==0) {//若某个未确定节点的入度为0
index=i;
cnt++;
in[i]--;
valid = true;
break;
}
}
if(!valid) {//如果在未确定的节点中,找不到入度为0的
cout<<"Wrong"<<endl;
break;
}
for(auto p : edge[index]) {//删除从节点index出发的边,将终点的入度减一
in[p]--;
}
}
if(cnt==n) cout<<"Correct"<<endl;
}
return 0;
}