拓扑排序
1. 拓扑排序原理
原理
- 只有有向无环图(DAG)才有拓扑排序。
- 具体做法是首先将图中入度为0的点入队,然后从这些点开始扩展,每出队一个点,将其所指向的点的度数减一,如果所指向的点的入度变为0了,则将其入队,直到队列为空为止。
- 最终整个队列中存储的就是拓扑排序后的结果。
- 如果队列中点的数目小于图中点的数目,说明图中存在环,不存在拓扑排序序列。
代码模板
#include <iostream>
#include <cstring>
using namespace std;
const int N = 100010, M = 100010;
int n, m;
int h[N], e[M], ne[M], idx;
int q[N]; // 普通队列
int d[N]; // 入度
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
bool topsort() {
int hh = 0, tt = -1;
for (int i = 1; i <= n; i++)
if (!d[i])
q[++tt] = i;
while (hh <= tt) {
int t = q[hh++];
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (--d[j] == 0)
q[++tt] = j;
}
}
return tt == n - 1;
}
int main() {
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
while (m--) {
int a, b;
scanf("%d%d", &a, &b);
add(a, b);
d[b]++;
}
if (!topsort()) puts("-1");
else {
for (int i = 0; i < n; i++) printf("%d ", q[i]);
printf("\n");
}
return 0;
}
2. AcWing上的拓扑排序题目
AcWing 1191. 家谱树
问题描述
-
问题链接:AcWing 1191. 家谱树
分析
- 直接使用拓扑排序的模板即可。
代码
- C++
#include <iostream>
#include <cstring>
using namespace std;
const int N = 110, M = N * N / 2;
int n;
int h[N], e[M], ne[M], idx;
int q[N]; // 普通队列
int d[N]; // 存储每个点的入度
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void topsort() {
int hh = 0, tt = -1;
for (int i = 1; i <= n; i++)
if (!d[i])
q[++tt] = i;
while (hh <= tt) {
int t = q[hh++];
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (--d[j] == 0)
q[++tt] = j;
}
}
}
int main() {
cin >> n;
memset(h, -1, sizeof h);
for (int i = 1; i <= n; i++) {
int son;
while (cin >> son, son) {
add(i, son);
d[son]++;
}
}
topsort();
for (int i = 0; i < n; i++)
cout << q[i] << ' ';
cout << endl;
return 0;
}
AcWing 1192. 奖金
问题描述
-
问题链接:AcWing 1192. 奖金
分析
-
这一题是一道差分约束的题目,但是是一个简化版的差分约束,可以使用差分约束求解,但是有种"大炮打蚊子"的感觉,这一题没必要使用差分约束求解。
-
此题让我们求最小值,我们应该使用最长路(不懂的话可以参考这里),题目的不等式条件是:
① a ≥ b + 1 a \ge b + 1 a≥b+1,相当于建一条从b指向a的边权为1的边;
② a ≥ 100 a \ge 100 a≥100,建立虚拟源点 x 0 = 0 x_0 = 0 x0=0,则 a ≥ x 0 + 100 a \ge x_0 + 100 a≥x0+100,建立一条从 x 0 x_0 x0到a边权为100的边。其实代码中不需要真实建立出来
-
关于差分约束的三道题目的对比:
(1)AcWing 1169. 糖果:关于这一题的讲解网址;这一题的边权是0或者1,其实是负数也可以,差分约束可以求解任意边权的问题。
(2)AcWing 368. 银河:关于这一题的讲解网址;抽象之后,这一题和AcWing 1169. 糖果是一模一样的,边权是0或者1,只要边权都是非负的,然后让我们求最长路,我们就可以使用有向图的强联通分量求解(如果边权都是非正的,然后让我们求最短路,我们也可以使用有向图的SCC求解)。
(3)AcWing 1192. 奖金:也就是本题,因为所有的边权都是相同的(为1),直接使用拓扑排序求解即可,若拓扑排序无解,说明图中一定含有正环,无解;否则有解的话说明是拓扑图,我们可以直接递推求解最长路径。
-
总结:这三题判断是否有解的方式不同,第(1)题使用spfa判断是否存在正环,第(2)题使用有向图的tarjan算法判断是否存在正环,第(3)题使用是否存在拓扑排序判断是否存在正环。如果可以确定图是DAG,那么就可以使用递推求解最长路或者最短路(边权是正是负无所谓)。
-
边权是任意值的情况下使用拓扑排序也可以判断出图中是否存在环,但是不能判断出是存在正环还是负环。
-
值得一提的是我们并不需要建立虚拟源点 x 0 x_0 x0,因为有解的话图一定是DAG,可以将所有点的初始距离初始化为100,然后递推求解即可。
代码
- C++
#include <iostream>
#include <cstring>
using namespace std;
const int N = 10010, M = 20010;
int n, m;
int h[N], e[M], ne[M], idx; // 权重只可能为1,因此不用存储
int q[N]; // 普通队列
int d[N]; // 入度
int dist[N]; // 距离
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
bool topsort() {
int hh = 0, tt = -1;
for (int i = 1; i <= n; i++)
if (!d[i])
q[++tt] = i;
while (hh <= tt) {
int t = q[hh++];
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (--d[j] == 0)
q[++tt] = j;
}
}
return tt == n - 1;
}
int main() {
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
while (m--) {
int a, b;
scanf("%d%d", &a, &b);
add(b, a);
d[a]++;
}
if (!topsort()) puts("Poor Xed");
else {
// 递推求最长路径
for (int i = 1; i <= n; i++) dist[i] = 100;
for (int i = 0; i < n; i++) {
int j = q[i];
for (int k = h[j]; ~k; k = ne[k])
dist[e[k]] = max(dist[e[k]], dist[j] + 1);
}
int res = 0;
for (int i = 1; i <= n; i++) res += dist[i];
printf("%d\n", res);
}
return 0;
}
AcWing 164. 可达性统计
问题描述
-
问题链接:AcWing 164. 可达性统计
分析
- 因为该图是一个有向无环图,因此我们可以在该图上使用DP求解,其实AcWing 1192. 奖金这个题递推过程本质上也是DP。
- 为什么DAG(有向无环图)上可以使用DP呢?这是因为DAG中不存在环,也就不存在状态间的循环依赖问题,俗称没有
后效性
。 - DP本质上就是有向无环图上的最短路问题。
- 我们在该DAG上求一遍拓扑排序,然后根据拓扑排序的逆序求解每一个 f [ i ] f[i] f[i]即可。
- 我们怎么表示每个集合呢?我们可以使用一个二维bool数组表示每个点可以达到的点,因为点数最多为三万,二维数组的空间达到了九亿,空间太大,另外时间复杂度大约是 O ( n 2 ) O(n^2) O(n2)量级的,也会超时,因此不能使用该策略。
- 我们可以使用C++中的STL容器,即bitset进行求解,因为int是32为,时间复杂度和空间复杂度都降为了大约三千万的量级,可行,关于C++的STL容器的使用,可以参考如下网址:C++常用STL及常用库函数。
代码
- C++
#include <iostream>
#include <cstring>
#include <bitset>
using namespace std;
const int N = 30010, M = 30010;
int n, m;
int h[N], e[M], ne[M], idx;
int q[N];
int d[N];
bitset<N> f[N];
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void topsort() {
int hh = 0, tt = -1;
for (int i = 1; i <= n; i++)
if (!d[i])
q[++tt] = i;
while (hh <= tt) {
int t = q[hh++];
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (--d[j] == 0)
q[++tt] = j;
}
}
}
int main() {
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
for (int i = 0; i < m; i++) {
int a, b;
scanf("%d%d", &a, &b);
add(a, b);
d[b]++;
}
topsort();
for (int i = n - 1; i >= 0; i--) {
int j = q[i];
f[j][j] = 1;
for (int k = h[j]; ~k; k = ne[k])
f[j] |= f[e[k]];
}
for (int i = 1; i <= n; i++) printf("%d\n", f[i].count());
return 0;
}
AcWing 456. 车站分级
问题描述
-
问题链接:AcWing 456. 车站分级
分析
- 对于每一个车次,都会在某个连续的区间运行,在这个区间内部(包含端点)有些车站会停车(这些车站的集合记为A),有些车站不会停车(这些车站的集合记为B),则A中所有元素的级别都严格大于B中车站的级别,即 a i ≥ b i + 1 a_i \ge b_i + 1 ai≥bi+1, a i ∈ A , b i ∈ B a_i \in A, b_i \in B ai∈A,bi∈B,另外还有 a i ≥ 1 a_i \ge 1 ai≥1,可以看出这是一个差分约束问题。
- 于是问题变为了让我们求解在满足上述两个不等式的条件下,在所有可能的车站编号方式(可能存在多种编号方式),对于每种编号方式里的所有车站编号都会存在一个最大值,在这些最大值中找到最小的那一个。
- 对于每一个车次,根据不等式,我们可以从 b i b_i bi向 a i a_i ai连一条边权为1的边。对于所有的车次都这样操作,我们就可以得到一个图。在这个图上求最长路即可,此时我们会得到一种最优的编号方式,即会得到每个车次的最小编号,取最大的那一个即可。
- 至此,这一题本质上就是AcWing 1192. 奖金这道题,但是问题有那么简单吗?答案是问题没有那么简单。
- 我们考虑需要建立多少条边,最多存在1000个车站,最多有1000个车次,最坏情况下对于每个车次我们需要建立 500 × 500 = 2.5 × 1 0 5 500 \times 500 = 2.5 \times 10 ^ 5 500×500=2.5×105条边,因此1000个车次需要建立 2.5 × 1 0 8 2.5 \times 10^8 2.5×108条边,这对于内存以及时间都是不可接受的,因此我们需要优化这一题的建边方式。
- 对于每个车次,我们不直接让集合B中所有的点连向集合A中所有的点,而是在两者之间建立一个虚拟源点,这样 500 × 500 500 \times 500 500×500就可以变成 500 + 500 500+500 500+500了,这样时间和空间都可以接收了,如下示意图:
- 在这个新图上求最长路即可。注意,假设图中有n个点,我们用1~n表示原来的点,代码实现中,使用n+i表示第i个虚拟源点(i从1开始)。
- 和AcWing 1192. 奖金这道题类似,递推的过程中我们并不需要在代码中显示的建立超级源点(根据 a i ≥ 1 a_i \ge 1 ai≥1可以建立超级源点),因为有解的话图一定是DAG,可以将所有点的初始距离初始化为100,然后递推求解即可。
- 考虑点数和边数的最大值,因为每个车次都要建议一个虚拟源点(注意和超级源点的区别),因此点数最多为2000;每个车次最多建立1000条边,因此最多建立一百万条边。
代码
- C++
#include <iostream>
#include <cstring>
using namespace std;
const int N = 2010, M = 1000010;
int n, m; // n个火车站, m个车次
int h[N], e[M], w[M], ne[M],idx;
int q[N]; // 拓扑排序使用的队列
int d[N]; // 每个点的入度
int dist[N]; // 每个点距离超级源点的最长距离
bool st[N]; // 记录每个车次中哪些车站需要停车
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
d[b]++; // b的入度加1
}
void topsort() {
int hh = 0, tt = -1;
for (int i = 1; i <= n + m; i++)
if (!d[i])
q[++tt] = i;
while (hh <= tt) {
int t = q[hh++];
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (--d[j] == 0)
q[++tt] = j;
}
}
}
int main() {
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
for (int i = 1; i <= m; i++) {
memset(st, 0, sizeof st);
int cnt; // 当前车次经停的车站数量
scanf("%d", &cnt);
int start = n, end = 1; // 起始站和终点站
while (cnt--) {
int stop;
scanf("%d", &stop);
start = min(start, stop);
end = max(end, stop);
st[stop] = true;
}
int ver = n + i; // 虚拟源点
for (int j = start; j <= end; j++)
if (!st[j]) add(j, ver, 0); // 集合B向ver连一条权值为0的边, 可以参考上面的分析
else add(ver, j, 1); // 从ver向会停车的集合A连一条权值为1的边
}
topsort();
// 递推求最长路
for (int i = 1; i <= n; i++) dist[i] = 1;
for (int i = 0; i < n + m; i++) { // 拓扑序列在q[0]~q[n+m-1]中
int j = q[i];
for (int k = h[j]; ~k; k = ne[k]) // 边(j, e[k])
dist[e[k]] = max(dist[e[k]], dist[j] + w[k]);
}
int res = 0;
for (int i = 1; i <= n; i++) res = max(res, dist[i]);
printf("%d\n", res);
return 0;
}
3. 力扣上的拓扑排序题目
Leetcode 0207 课程表
问题描述
-
问题链接:Leetcode 0207 课程表
分析
- 直接使用拓扑排序即可。
代码
- C++
/**
* 执行用时:44 ms, 在所有 C++ 提交中击败了35.62%的用户
* 内存消耗:12.8 MB, 在所有 C++ 提交中击败了28.56%的用户
*/
class Solution {
public:
bool canFinish(int n, vector<vector<int>> &edges) {
vector<vector<int>> g(n);
vector<int> d(n); // 存储入度
for (auto &e : edges) {
int b = e[0], a = e[1];
g[a].push_back(b);
d[b]++;
}
queue<int> q;
for (int i = 0; i < n; i++)
if (d[i] == 0)
q.push(i);
int cnt = 0;
while (q.size()) {
auto a = q.front();
q.pop();
cnt++;
for (auto b : g[a])
if (--d[b] == 0)
q.push(b);
}
return cnt == n;
}
};
- Java
/**
* Date: 2021/1/31 10:55
* 执行用时:5 ms, 在所有 Java 提交中击败了72.23%的用户
* 内存消耗:39.4 MB, 在所有 Java 提交中击败了22.93%的用户
*/
public class Solution {
public boolean canFinish(int n, int[][] edges) {
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < n; i++) g.add(new ArrayList<>());
int[] d = new int[n]; // 存储入度
for (int[] e : edges) {
int b = e[0], a = e[1];
g.get(a).add(b);
d[b]++;
}
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < n; i++)
if (d[i] == 0)
q.add(i);
int cnt = 0;
while (!q.isEmpty()) {
int a = q.remove();
cnt++;
for (int b : g.get(a))
if (--d[b] == 0)
q.add(b);
}
return cnt == n;
}
}
Leetcode 0210 课程表 II
问题描述
-
问题链接:Leetcode 0210 课程表 II
分析
- 直接使用拓扑排序即可。
代码
- C++
/**
* 执行用时:20 ms, 在所有 C++ 提交中击败了98.80%的用户
* 内存消耗:13 MB, 在所有 C++ 提交中击败了96.10%的用户
*/
class Solution {
public:
vector<int> findOrder(int n, vector<vector<int>> &edges) {
vector<vector<int>> g(n);
vector<int> d(n); // 入度
for (auto &e : edges) {
auto b = e[0], a = e[1];
g[a].push_back(b);
d[b]++;
}
queue<int> q;
for (int i = 0; i < n; i++)
if (d[i] == 0)
q.push(i);
vector<int> res;
while (q.size()) {
auto a = q.front();
q.pop();
res.push_back(a);
for (auto b : g[a])
if (--d[b] == 0)
q.push(b);
}
if (res.size() < n) res = {};
return res;
}
};
- Java
/**
* Date: 2021/1/31 11:06
* 执行用时:5 ms, 在所有 Java 提交中击败了76.64%的用户
* 内存消耗:39.7 MB, 在所有 Java 提交中击败了40.87%的用户
*/
public class Solution {
public int[] findOrder(int n, int[][] edges) {
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < n; i++) g.add(new ArrayList<>());
int[] d = new int[n]; // 存储入度
for (int[] e : edges) {
int b = e[0], a = e[1];
g.get(a).add(b);
d[b]++;
}
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < n; i++)
if (d[i] == 0)
q.add(i);
int[] res = new int[n];
int cnt = 0;
while (!q.isEmpty()) {
int a = q.remove();
res[cnt++] = a;
for (int b : g.get(a))
if (--d[b] == 0)
q.add(b);
}
if (cnt < n) return new int[]{};
return res;
}
}
Leetcode 1203 项目管理
问题描述
-
问题链接:Leetcode 1203 项目管理
分析
- 因为本题有要求:
同一小组的项目,排序后在列表中彼此相邻。
,所以我们必须做两次拓扑排序,如果没有这个要求,我们直接根据项目之间的关系求解一遍即可。 - 首先我们需要将每个组看成一个点,组之间的边根据项目之间的关系确定,然后对组进行拓扑排序;
- 如果上面发现没有矛盾(即组间存在拓扑序),之后在每个组内进行拓扑排序,得到最终的结果。
代码
- C++
/**
* 超出时间限制
*/
class Solution {
public:
vector<int> topoSort(int deg[], vector<int> items, vector<vector<int>> g) {
queue<int> q;
for (int id : items) {
if (!deg[id])
q.push(id);
}
vector<int> res;
while (!q.empty()) {
int u = q.front();
q.pop();
res.push_back(u);
for (int v : g[u])
if (--deg[v] == 0)
q.push(v);
}
if (res.size() != items.size()) res.clear();
return res;
}
vector<int> sortItems(int n, int m, vector<int> &group, vector<vector<int>> &beforeItems) {
vector<vector<int>> groupItem(n + m); // 项目分组
int t = m; // 新的组号从m开始,不会和原来的组号(0~m-1)冲突
for (int i = 0; i < group.size(); i++) {
if (group[i] == -1) group[i] = t++; // 说明项目i 单独一组
groupItem[group[i]].push_back(i); // 同一组的放在一起
}
vector<vector<int>> in(n); // 组内拓扑关系
vector<vector<int>> out(n + m); // 组间拓扑关系
// 拓扑排序需要的入度
int degIn[n]; // 组内入度
int degOut[n + m]; // 组间入度
memset(degIn, 0, sizeof(degIn));
memset(degOut, 0, sizeof(degOut));
// 建图、求入度
for (int to = 0; to < beforeItems.size(); ++to) {
vector<int> before = beforeItems[to]; // 项目to所依赖的项目
int toId = group[to]; // 当前项目to所属的组id
for (int from : before) {
if (group[from] == toId) { // 同一组内拓扑排序
degIn[to]++;
in[from].push_back(to); // 组内建图,添加边(from, to)
} else {
degOut[toId]++;
out[group[from]].push_back(toId); // 组间建图,添加边(group[from], toId)
}
}
}
// 组间拓扑排序
vector<int> groupId;
for (int i = 0; i < n + m; i++) groupId.push_back(i);
vector<int> outSort = topoSort(degOut, groupId, out);
if (outSort.empty()) return vector<int>{}; // 说明组间存在环
// 组内拓扑排序
vector<int> res;
for (int gid : outSort) { // 根据组间拓扑序遍历每一组
vector<int> items = groupItem[gid];
if (items.empty()) continue;
vector<int> inSort = topoSort(degIn, items, in);
if (inSort.empty()) return vector<int>{}; // 说明组内存在环
for (int i : inSort) res.push_back(i);
}
return res;
}
};
- Java
/**
* Date: 2021/1/12 16:44
* 分为组间拓扑排序 和 组内拓扑排序,先组间后组内
* 执行用时:48 ms, 在所有 Java 提交中击败了46.58%的用户
* 内存消耗:58.7 MB, 在所有 Java 提交中击败了52.17%的用户
*/
public class Solution {
// deg : 入度, items : 图中的点, g : 图
private List<Integer> topoSort(int[] deg, List<Integer> items, List<List<Integer>> g) {
Queue<Integer> q = new LinkedList<>();
for (Integer id : items) {
if (deg[id] == 0)
q.add(id);
}
List<Integer> res = new ArrayList<>();
while (!q.isEmpty()) {
int u = q.remove();
res.add(u);
for (int v : g.get(u))
if (--deg[v] == 0)
q.add(v);
}
// 如果所有点都入队了,说明存在拓扑序列;否则不存在拓扑序列。
return res.size() == items.size() ? res : new ArrayList<>();
}
public int[] sortItems(int n, int m, int[] group, List<List<Integer>> beforeItems) {
List<List<Integer>> groupItem = new ArrayList<>(); // 项目分组
for (int i = 0; i < n + m; i++) groupItem.add(new ArrayList<>()); // 初始化小组
int t = m; // 新的组号从m开始,不会和原来的组号(0~m-1)冲突
for (int i = 0; i < group.length; i++) {
if (group[i] == -1) group[i] = t++; // 说明项目i 单独一组
groupItem.get(group[i]).add(i); // 同一组的放在一起
}
List<List<Integer>> in = new ArrayList<>(); // 组内拓扑关系
List<List<Integer>> out = new ArrayList<>(); // 组间拓扑关系
for (int i = 0; i < n; i++) in.add(new ArrayList<>()); // 组内项目编号(0~n-1)
for (int i = 0; i < n + m; i++) out.add(new ArrayList<>()); // 组间编号最大到 n+m-1
// 拓扑排序需要的入度
int[] degIn = new int[n]; // 组内入度
int[] degOut = new int[n + m]; // 组间入度
// 建图、求入度
for (int to = 0; to < beforeItems.size(); to++) {
List<Integer> before = beforeItems.get(to); // 项目to所依赖的项目
int toId = group[to]; // 当前项目to所属的组id
for (Integer from : before) {
if (group[from] == toId) { // 同一组内拓扑排序
degIn[to]++;
in.get(from).add(to); // 组内建图,添加边(from, to)
} else {
degOut[toId]++;
out.get(group[from]).add(toId); // 组间建图,添加边(group[from], toId)
}
}
}
List<Integer> groupId = new ArrayList<>(); // 所有组id,可能组内没有顶点
for (int i = 0; i < n + m; i++) groupId.add(i);
List<Integer> outSort = topoSort(degOut, groupId, out);
if (outSort.size() == 0) return new int[0]; // 组间无法拓扑排序
int[] res = new int[n];
int index = 0;
for (Integer gid : outSort) { // 遍历排序后的所有小组id
List<Integer> items = groupItem.get(gid); // 根据小组id 拿到这一小组中的所有成员
if (items.size() == 0) continue; // 说明这组里面没有节点
List<Integer> inSort = topoSort(degIn, items, in);
if (inSort.size() == 0) return new int[0]; // 组间无法拓扑排序
for (Integer i : inSort) res[index++] = i;
}
return res;
}
}