有向无环图
定义
边有向,无环。英文名叫 Directed Acyclic Graph,缩写是 DAG。一些实际问题中的二元关系都可使用 DAG 来建模。
性质
-
能 拓扑排序 的图,一定是有向无环图;
如果有环,那么环上的任意两个节点在任意序列中都不满足条件了。
-
有向无环图,一定能拓扑排序;
(归纳法)假设节点数不超过 kk 的 有向无环图都能拓扑排序,那么对于节点数等于 kk 的,考虑执行拓扑排序第一步之后的情形即可。
判定
如何判定一个图是否是有向无环图呢?
检验它是否可以进行 拓扑排序 即可。
当然也有另外的方法,可以对图进行一遍 DFS ,在得到的 DFS 树上看看有没有连向祖先的非树边(返祖边)。如果有的话,那就有环了。
拓扑排序
定义
拓扑排序的英文名是 Topological sorting。
拓扑排序要解决的问题是 给一个图的所有节点排序。
我们可以以游戏闯关为例。以下图为例,我们初始只能从第一关开始打,当第 11 关打完后,可以依次打第 2,3,42,3,4 关,当第 44 关打完后,第 5∼95∼9 关都可以打,以此类推,某个关卡打完之后,其相邻的关卡就可以打了,否则不能打。现在让你安排一个合理的闯关顺序,来将所有的关卡都完成。
上面这个游戏闯关图,我们可以将其抽象为一个以关卡 11 为根的一棵树,或者说是有向无环图,边的方向为从编号小的节点走向编号大的节点。我们可以直接按 1∼321∼32 的顺序依次闯关,或者其他合法的顺序闯关。但是如果我们想把第 11 关闯完后,就直接闯第 2828 关,就不可以。因为闯第 2828 关之前,必须保证第 1313 关先闯完了,才可以。
一个合法的满足规则的闯关顺序就是一个拓扑排序。
实际上的拓扑排序是在更复杂的图上进行的,以下图为例:
对其排序的一种合法的结果就是:2→8→0→3→7→1→5→6→9→4→11→10→122→8→0→3→7→1→5→6→9→4→11→10→12。
但是如果图上出现了一个环,那么,我们就无法对图进行拓扑排序了。这也就是上面我们说的结论:只有有向无环图(DAG)上才可以进行拓扑排序。
因此我们可以说: 在一个 DAG(有向无环图) 中,我们将图中的顶点以线性方式进行排序,使得对于任何的顶点 uu 到 vv 的有向边 (u,v)(u,v) , 都可以有 uu 在 vv 的前面。
还有给定一个 DAG,如果从 ii 到 jj 有边,则认为 jj 依赖于 ii 。如果 ii 到 jj 有路径( ii 可达 jj ),则称 jj 间接依赖于 ii 。
拓扑排序的目标是将所有节点排序,使得排在前面的节点不能依赖于排在后面的节点。
实现拓扑排序可以用 Kahn 算法和 DFS 算法实现,下面逐一介绍。
Kahn 算法
基本原理
Kahn 算法基于以下思想实现:
- 一条入边即为一条依赖,有多少入度,就有多少个依赖。
- 对于所有未访问的节点,我们必须从入度为 00 的节点开始访问。
- 访问一个节点后,其节点的出边所造成的依赖消失,该出边对应的入度就减少。
因此,Kahn 算法实现步骤如下:
-
初始状态下,集合 SS 装着所有入度为 00 的点, LL 是一个空列表。
-
每次从 SS 中取出一个点 uu (可以随便取)放入 LL , 然后将 uu 的所有边 (u,v1),(u,v2),(u,v3)⋯(u,v1),(u,v2),(u,v3)⋯ 删除。对于边 (u,v)(u,v) ,若将该边删除后点 vv 的入度变为 00 ,则将 vv 放入 SS 中。
-
不断重复以上过程,直到集合 SS 为空。
-
检查图中是否存在任何边或者还有节点没有被访问过,如果有,那么这个图一定至少存在一条环路,否则返回 LL , LL 中顶点的顺序就是拓扑排序的结果。
模板题:P2243 - 拓扑排序
示例代码
#include <bits/stdc++.h>
using namespace std;
const int N = 1e3+5;
int n, m, x, y;
vector<int> g[N];
vector<int> ans;
int cnt[N], tpc;
// 判断有无环
bool kahn()
{
queue<int> q;
for(int i = 1; i <= n; i++) { // 将入度为 0 的点入队
if(!cnt[i])
q.push(i);
}
while(!q.empty())
{
int u = q.front();
q.pop();
tpc++; // 统计访问过的节点数量
ans.push_back(u); // 压入已访问节点列表
for(auto v : g[u])
{
cnt[v]--;
if(!cnt[v]) // 入度为 0, 没有任何依赖,可以访问
q.push(v);
}
}
return tpc == n; // 如果所有节点都访问了,说明不存在环,否则有环
}
int main()
{
cin >> n >> m;
while(m--)
{
cin >> x >> y;
g[x].push_back(y);
cnt[y]++; // 统计各个节点的入度
}
if(!kahn())
cout << "loop exist.";
else
{
cout << "loop not exist." << endl;
// for(int i = 0; i < ans.size(); i++)
// cout << ans[i] << " ";
for(auto x : ans) cout << x << " ";
}
return 0;
}
Copy
代码的核心是维持一个入度为 00 的顶点的集合,这里使用了队列,当然,换成栈或者优先队列等都可以。这样,使用不同的数据结构维持入度为 00 的顶点的集合,其最终的访问的顺序也不尽相同。
所以我们从代码也可以看出: 对于一个 DAG 图,拓扑排序的结果可能不唯一。
时间复杂度
假设这个图 G=(V,E)G=(V,E) 在初始化入度为 00 的集合 SS 的时候就需要遍历整个图,并检查每一条边,因而有 O(E+V)O(E+V) 的复杂度。然后对该集合进行操作,显然也是需要 O(E+V)O(E+V) 的时间复杂度。
因而总的时间复杂度就有 O(E+V)O(E+V) 。
DFS 算法
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5+5;
int n, m, x, y;
vector<int> G[N]; // vector 实现的邻接表
int c[N]; // 标志数组 -1:访问过但仍有出度,0: 未访问,1: 出度为 0 的节点
vector<int> topo; // 拓扑排序后的节点
bool dfs(int u) {
c[u] = -1;
for (int v : G[u]) {
if (c[v] < 0) return false; // 节点被访问过了,有返祖边,有环
// 节点出度不为 0,但是从该节点出发有返祖边,有环
if (!c[v] && !dfs(v)) return false;
}
c[u] = 1; // 节点 u 的所有出边已访问完,出度为 0 了,入拓扑节点列表
topo.push_back(u);
return true;
}
bool toposort() {
topo.clear();
memset(c, 0, sizeof(c));
for (int u = 1; u <= n; u++) {
if (!c[u]&&!dfs(u))
return false;
}
reverse(topo.begin(), topo.end());
return true;
}
int main()
{
cin >> n >> m;
while(m--)
{
cin >> x >> y;
G[x].push_back(y);
}
if(!toposort())
cout << "loop exist.";
else
{
cout << "loop not exist." << endl;
for(auto x : topo)
cout << x << " ";
}
return 0;
}
时间复杂度: O(E+V)O(E+V)
空间复杂度: O(V)O(V)
合理性证明
这个算法的实现非常简单,但是要理解的话就相对复杂一点。
关键在于为什么在 dfs 方法的最后将该顶点添加到一个集合中,就能保证这个集合就是拓扑排序的结果呢?
因为添加顶点到集合中的时机是在 dfs 方法即将退出之时,而 dfs 方法本身是个递归方法,只要当前顶点还存在边指向其它任何顶点,它就会递归调用 dfs 方法,而不会退出。因此,退出 dfs 方法,意味着当前顶点没有指向其它顶点的边了,即当前顶点是一条路径上的最后一个顶点。
两种方法的对比
这两种算法分别使用队列和栈来表示结果集。
对于基于DFS的算法,加入结果集的条件是:顶点的出度为 00。这个条件和 Kahn 算法中入度为 00 的顶点集合似乎有着异曲同工之妙,这两种算法的思想犹如一枚硬币的两面,看似矛盾,实则不然。一个是从入度的角度来构造结果集,另一个则是从出度的角度来构造。
二者的复杂度均为 O(V+E)O(V+E)。
应用与问题变形
判环
拓扑排序可以用来判断图中是否有环,还可以用来判断图是否是一条链。
字典序最大/最小的拓扑排序
对于 Kahn 算法,我们可以将 Kahn 算法中的队列替换成最大堆/最小堆实现的优先队列,这样就可以求出字典序最大/最小的拓扑排序,此时总的时间复杂度为 O(E+VlogV)O(E+VlogV) 。
拓扑排序解的唯一性
哈密顿路径:哈密顿路径是指一条能够对图中所有顶点正好访问一次的路径。
接下来我们只会解释一些哈密顿路径和拓扑排序的关系,至于哈密顿路径的具体定义以及应用,可以参见其他文章或自行搜索了解。
前面说过,当一个 DAG 中的任何两个顶点之间都存在可以确定的先后关系时,对该 DAG 进行拓扑排序的解是唯一的。这是因为它们形成了全序的关系,而对存在全序关系的结构进行线性化之后的结果必然是唯一的(比如对一批整数使用稳定的排序算法进行排序的结果必然就是唯一的)。
需要注意的是, 非 DAG 也是能够含有哈密顿路径的,但是为了利用拓扑排序来实现判断,这里讨论的主要是判断 DAG 中是否含有哈密顿路径的算法。
我们先使用拓扑排序对图中的顶点进行排序。排序后,对每对相邻顶点进行检测,看看是否存在先后关系,如果每对相邻顶点都存在着一致的先后关系(在有向图中,这种先后关系以有向边的形式体现,即查看相邻顶点对之间是否存在有向边)。那么就可以确定该图中存在哈密顿路径了,反之则不存在。