1. 术语
a. 有向无环图
如果一个有向图的任一个点都无法通过一些有向边回到自身,则称这个图为 有向无环图
D
A
G
DAG
DAG。
b. 拓扑排序
如果存在边 (u,v)
,则拓扑排序中 u
一定在 v
的前面。
2. 拓扑排序判断 D A G DAG DAG
对于一张有向图 ( G , E ) (G, E) (G,E),如果可以将所有的顶点组成一个线性排序,满足 u u u 在 v v v 的前面,其中, ( u , v ) ∈ E (u, v) \in E (u,v)∈E,即,可以将所有顶点组成一个 拓扑排序,那么图 ( G , E ) (G, E) (G,E) 为有向无环图 D A G DAG DAG。
具体实现:
- 定义一个队列 q q q,把所有入度为 0 0 0 的结点加入到 q q q。
- 取队首结点,输出。删去所有从他出发的边,令这些边的另一端的结点 入度 − 1 -1 −1。如果某个顶点的入度减为 0 0 0,则将其入队 q q q。
- 反复执行,直到 q q q 为空。
- 如果输出的结点数恰好为 n n n,则拓扑排序成功,图 ( G , E ) (G, E) (G,E) 为有向无环图 D A G DAG DAG,反之,图 ( G , E ) (G, E) (G,E) 中有环。
【注】邻接点 可以用邻接矩阵(耗空间,适用于点数很小的情况) / 邻接表(推荐) 存储。
// 邻接表实现
vector<int> g[maxn];
int n,m,inD[maxn];
bool topologicalSort()
{
int num=0;
queue<int> q;
vector<int> inD(n, 0); // 存储每个点的入度
vector<vector<int> > g(n + 1);
for(auto x : edges) // 邻接表存储边
{
g[x[1]].emplace_back(x[0]);
inD[x[0]]++;
}
for(int i = 0; i < n; i++)
if(inD[i] == 0) // 入度为 0 的顶点入队列
q.push(i);
while(!q.empty())
{
int u = q.front();
q.pop();
num++; // 每取出一个入度为 0 的点,num++
for(int i = 0; i < g[u].size(); i++) // 遍历邻接点,修改邻接点的入度
{
int v = g[u][i];
inD[v]--; //入度--
if(inD[v] == 0) // 出现了新的入度为 0 的顶点,入队
q.push(v);
}
// g[u].clear(); //清空顶点u出发的所有边,可忽略
}
if(num == n) return true;
return false;
}
// 邻接矩阵实现
bool topologicalSort(){
vector<vector<int> > g(n + 1, vector<int>(n + 1, 0));
vector<int> in(n + 1, 0);
vector<int> vis(n + 1, 0); // 额外建立一个数组记录顶点的访问情况
for(auto x : edges) // 邻接矩阵存储边
{
g[x[1]][x[0]] = 1;
in[x[0]]++;
}
int cnt = 0;
while(cnt < n) // 当获取 n 个入度为 0 的顶点时,结束循环
{
int u = -1;
for(int i = 0; i < n; i++)
{
if(in[i] == 0 && vis[i] == 0) // 入度为 0 且未被访问过
{
u = i;
vis[u] = 1;
break;
}
}
if(u == -1) return false; // 没有入度为 0 的节点了
for(int i = 0; i < numCourses; i++)
{
if(g[u][i] == 1) //存在边,入度--
in[i]--;
}
cnt++;
}
return true;
}
【注】 如果要求有多个入度为
0
0
0 的顶点时选择序号最小的,把queue
改成priority_queue
,或者set
也可以。
【注】 邻接表 + 队列 实现时,出现的入度为
0
0
0 的顶点一定是之前未被访问过的(因为被遍历到的邻接点
v
v
v 至少存在边
(
u
,
v
)
(u, v)
(u,v) ,即,入度至少为
1
1
1),所以不需要 vis
数组;而邻接矩阵实现时,每一次都会遍历所有顶点,所以需要记录那些入度为
0
0
0 的顶点已经被计算过了,所以需要 vis
数组,进而防止重复计算。
3. 拓扑排序其他例题
2127. 参加会议的最多员工数
题意
- 每个员工 有且只有一个 喜欢的员工,且一定不是自己;
- 员工必须和喜欢的员工坐在一起;
- 求可参加会议的最多员工数。
解法 拓扑排序 + 动态规划
比较容易想到这是一个图的问题。
需要注意的第一个点是:
- 一共
n
n
n 个节点,
n
n
n 条边,并且每个节点的出度为
1
1
1,所以 图里一定有环,并且,会构成一种特殊的结构——基环内向树 / 基环内向森林(环外的点都直接或间接指向环)。
【注】可以有多个环,但是环之间不可能相连。
【注】也就是说,这个图应当是由类似下图的结构的若干个(一个或多个)的组合。
需要注意的第二个点是:
- 大小为 2 2 2 的环是特殊的。
≥ 3 \geq 3 ≥3 的环:
- 等价于寻找环所在的最长路径,如,a->b->c->环,即 连接到环上的最长子路径的长度 + 环的长度。
- 注意到,指向环的子路径,如 a->b->c,是一条拓扑排序,那么,就可以使用拓扑排序来计算连接到环上的最长子路径的长度。
- 同时应当注意到,在寻找完拓扑排序后,环上的点是一定不会进入队列的,因为环上的点入度不可能为 0 0 0。
- 因此,拓扑排序的最大值 + 环的长度,就是这样一棵基环内向数可以参加会议的最大人数。
- 由于数据范围很大,所以使用 动态规划 来计算拓扑排序的最大长度,更具体地说,是计算环上节点所在的拓扑排序的最大长度。
- 设 f [ i ] f[i] f[i] 表示到点 i i i 的拓扑排序的最大长度
- 初始化 f [ i ] = 1 f[i] = 1 f[i]=1。因为自身节点也算长度。
- 更新 f [ i ] = m a x ( f [ i ] , f [ j ] + 1 ) f[i] = max(f[i], f[j] + 1) f[i]=max(f[i],f[j]+1),其中 ( j , i ) ∈ E (j, i) \in E (j,i)∈E。
- 【注】拓扑排序的环上的连接点不要重复算。
= 2 = 2 =2 的环:
- 由于大小为 2 2 2 的环,员工相互喜欢,所以此时的答案应当是 所有 连接到环上的最长子路径的长度(不包括环上的点) + 环的长度 + 连接到环上的第二场长子路径的长度 之和。
- 所以寻找所有 = 2 = 2 =2 的环,相加。
- 【注】拓扑排序的环上的连接点不要重复算。
- 【注】区别在于, ≥ 3 \geq 3 ≥3 的环是出不了圈的,因为喜欢的员工一定在圈内,而 = 2 = 2 =2 的环是可以出圈的。
class Solution {
public:
int maximumInvitations(vector<int>& favorite) {
int n = favorite.size();
vector<int> indeq(n, 0), f(n, 1);
vector<int> vis(n, 0);
for(int i = 0; i < n; i++)
{
indeq[favorite[i]]++;
}
queue<int> q;
for(int i = 0; i < n; i++)
{
if(!indeq[i]) q.push(i);
}
// 拓扑排序
while(!q.empty())
{
int u = q.front();
q.pop();
vis[u] = 1; // 标记环外的点
int v = favorite[u];
f[v] = max(f[v], f[u] + 1); // 更新最长的拓扑排序
indeq[v]--;
if(!indeq[v]) q.push(v);
}
// ring: 环大于2的最长游走路径
// total: 环为2的游走路径之和
int ring = 0, total = 0;
for(int i = 0; i < n; i++)
{
// 环上的点
if(!vis[i])
{
int j = favorite[i];
if(favorite[j] == i) // 相互喜欢
{
total += f[i] + f[j];
vis[i] = vis[j] = 1; // 判断过的点必须标记,否则会重复加
}
else
{
int circle_len = 1;
int u = i;
while(favorite[u] != i)
{
circle_len++;
vis[u] = 1; // 这个环上的点都被遍历过了
u = favorite[u];
}
ring = max(ring, circle_len);
}
}
}
return max(total, ring);
}
};
复杂度
时间复杂度:
O
(
n
)
O(n)
O(n),每个点最多遍历一遍。
空间复杂度:
O
(
n
)
O(n)
O(n),数组。