拓扑排序
今天2025.10.16
经过 \(hwh\) 和 \(cxy\) 的指导,我也是初步学会了 \(markdown\),必须得写一篇文章练练手。
写得格式可能不太好,没逝,一定会好起来的
好了,开始说说今天学的东西—— 拓扑排序
事实上我早就看过了,只是一直没实践,今天一上手,发现拓扑排序的绿题确实简单
\(DAG\) 定义
众所周知,我们在生活中,经常会遇到要做的一些事情要有一定顺序,例如:
要做饭,得买菜,然后洗菜、切菜,接着进行一系列的科技与狠活 煮饭(或者蒸,炸,煎等操作),最后,饭就做好了。
用这个例子就是说,我在做饭之前需要进行一系列的准备,都做完以后,才可以做饭,这就有了各个事情之间的先后顺序
我们就可以从中抽象出数学模型了——\(DAG\)(有向无环图)
仍以做饭为例:
这就是一个\(DAG\),当然,我们OI肯定不可能把汉字作为节点,所以,我们就再次把每个事件进行编号,就变成了比较好看的图:
度
和某节点相连的边的数量就是该节点的度
又分为入度和出度
某节点的入度就是该节点被几条边入 开玩笑,就是以该节点为结尾的边的数量,例如上图的7个节点从1至7的入度依次为:
\(0,1,1,1,1,1,3\)
节点的出度自然就是以该节点为起点的边的数量,例如上图的7个节点从1至7的出度依次为:
\(1,1,3,1,1,1,0\)
拓扑序
拓扑序列是有向图中将所有顶点排列成满足前驱关系的线性序列,由某个集合上的偏序关系转化为全序关系的操作结果
不必看这个定义,我说一下自己的理解,其实就是所做的每件事情的先后顺序就是一个拓扑序,仍然以做饭为例:
买菜 \(\rightarrow\) 洗菜 \(\rightarrow\) 切菜 \(\rightarrow\) 煮 \(\rightarrow\) 炸 \(\rightarrow\) 蒸 \(\rightarrow\) 上菜
这就是一个拓扑序。不难发现,煮、炸、蒸,之间是并列关系,所以,还有别的拓扑序:
买菜 \(\rightarrow\) 洗菜 \(\rightarrow\) 切菜 \(\rightarrow\) 炸 \(\rightarrow\) 煮 \(\rightarrow\) 蒸 \(\rightarrow\) 上菜
买菜 \(\rightarrow\) 洗菜 \(\rightarrow\) 切菜 \(\rightarrow\) 煮 \(\rightarrow\) 蒸 \(\rightarrow\) 炸 \(\rightarrow\) 上菜
.....
一共 \(3!=6\) 种拓扑序,不一一列出了
变成数字编号:
\(1,2,3,4,5,6,7\)
\(1,2,3,5,4,6,7\)
\(1,2,3,6,4,5,7\)
...
还是 \(6\) 种,区别就是 \(4,5,6\) 的排列不同
当然了,拓扑序用字母表示节点也可以:
好了,基础知识就介绍到这里了。
代码思路
求 \(n\) 个节点 \(m\) 条边的 \(DAG\) 的一个拓扑:
首先维护一个队列,我们每次都要将没入过队的且入度为 \(0\) 的节点加入这个队列,然后每次取出队头,接着把这个节点的子节点都入队,不停进行下去,直到队列为空。
输出一种拓扑序 只需在节点出队的时候输出该节点就可以(记住,必须是 \(DAG\) 才可以这样输出,如果题目 不保证无环或连通 就不能这样,需要另开一个 \(ans\) 队列记录弹出的节点,最后判断一下 ans.size()
是否等于 \(n\) ,如果为 真,就把队列里的数依次弹出并且输出即可,否则就是无解的(不联通或者有环) )(一般题目都会有 Special Judge,没有的话就 自求多福吧, 看情况修改输出)
复杂度易证:\(O(n+m)\) (因为输入 \(m\) 条边,且遍历图的时候,\(n\) 个节点都被遍历到了)
补充:不难发现,一般来说,题目所给的图都是森林,所以为了 偷懒 方便,直接建立超级源点,让初始所有入度为 \(0\) 的点都成为超级源点的儿子。超级源点很多时候能把题目所给的节点信息都变成边权信息,所以必须得会,本文不多介绍,其实很简单,知道定义以后自己都会编码。
例题
B3644 【模板】拓扑排序 / 家谱树
简介:
给了 \(N\) ( \(1≤N≤100\) )个人,接下来 \(N\) 行,第 \(i\) 行给出第 \(i\) 个人的后代,输入 \(0\) 时结束这一行。
输出:
输出一个序列,使得每个人的后辈都比那个人后列出。如果有多种不同的序列,输出任意一种即可。
样例:
输入:
5
0
4 5 1 0
1 0
5 3 0
3 0输出:
2 4 5 3 1
不废话了,板题直接上代码:
//为了偷懒所以建了超级源点,但是,这确实是个好东西,后面的题会用到,不如提前学会这种写法
#include<bits/stdc++.h>
using namespace std;
const int N=105,M=N*N;
int n,cnt,ans[N],d[N],hd[N],nxt[M],to[M];//我的链式前向星一般不写结构体(但是从内存访问的常数角度上来说,结构体常数更小,但是分开的数组码代码的时候比较快)我就直接分开写了
void add(int a,int b){//链式前向星加边,本题 N 较小,直接邻接矩阵也可以nxt[++cnt]=hd[a];hd[a]=cnt;to[cnt]=b;
}
void Toposort(int s){//拓扑排序主体queue<int>q;//定义队列q.push(s);//加入超级源点while(!q.empty()){int u=q.front();//取队首q.pop();if(u!=n+1) cout<<u<<" ";//不是超级源点的就输出(根据题意,一定有解,直接输出就好了)for(int i=hd[u];i;i=nxt[i]){int t=to[i];//遍历儿子节点if(--d[t]==0){//给儿子节点的入度-1q.push(t);//如果儿子节点入度为0,就入队}}}
}
int main(){cin>>n;int S=n+1;//建立超级源点for(int i=1;i<=n;i++){int k;while(1){cin>>k;if(k==0) break;d[k]++;//儿子节点入度+1add(i,k);//加边}}for(int i=1;i<=n;i++) if(d[i]==0) d[i]++,add(S,i);//入度为零的点,直接变成超级源点的儿子Toposort(S);//求拓扑序,从超级源点开始return 0;
}
P4017 最大食物链计数
题目自己看就好了,一道很简单的 \(DAG上的DP\) 题
思路:
不难发现,只需把父节点的路径数加到其子节点上即可。初始 入度为0 的节点,路径数 初始化为1,输出 所有出度为0的节点的路径数之和 就是答案
当然,依旧用 超级源点 和 超级汇点 ,这样只需 初始化超级源点的路径数为1,最后输出 超级汇点的路径数 即为答案
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=5005,M=5e5+5,mod=80112002;
int n,m,cnt,d1[N],d2[N],hd[N],nxt[M],to[M],cnts[N];//d1为入度,d2为出度,cnts数组是路径数
void add(int a,int b){nxt[++cnt]=hd[a];hd[a]=cnt;to[cnt]=b;
}
void Toposort(int s){queue<int>q;q.push(s);cnts[s]=1;while(!q.empty()){int u=q.front();q.pop();for(int i=hd[u];i;i=nxt[i]){int t=to[i];cnts[t]=(cnts[t]+cnts[u])%mod;if(--d1[t]==0) q.push(t);}}
}
int main(){cin>>n>>m;int S=n+1,T=n+2;//超级源点S,超级汇点Tfor(int i=1;i<=m;i++){int a,b;cin>>a>>b;add(b,a);d1[a]++;d2[b]++;}for(int i=1;i<=n;i++) if(d1[i]==0) d1[i]++,add(S,i);//初始入度为0,加入超级汇点的儿子for(int i=1;i<=n;i++) if(d2[i]==0) d2[i]++,add(i,T);//出度为0,加入超级汇点的父亲Toposort(S);cout<<cnts[T];return 0;
}
P1347 排序
这道题就是需要判断是否不连通或者有环的情况
#include<bits/stdc++.h>
using namespace std;
#define mp(c) c-'A'+1
const int M=605;
int n,m,cnt,hd[28],nxt[M],to[M],c1,d[28],d1[28],a[M],b[M];
bool vis[28];
void add(int a,int b){nxt[++cnt]=hd[a];hd[a]=cnt;to[cnt]=b;d[b]++;
}
bool Toposort(int k){queue<int>q,ans;bool flag=false;for(int i=1;i<=n;i++) d1[i]=d[i];for(int i=1;i<=n;i++) if(vis[i]&&d1[i]==0) q.push(i);if(q.size()>1) flag=true;while(!q.empty()){int u=q.front();q.pop();ans.push(u);for(int i=hd[u];i;i=nxt[i]){int t=to[i];if(--d1[t]==0) q.push(t);if(q.size()>1) flag=true;}}if(ans.size()<c1){cout<<"Inconsistency found after "<<k<<" relations.";return true;}if(ans.size()<n) return false;if(flag) return false;cout<<"Sorted sequence determined after "<<k<<" relations: ";while(!ans.empty()){cout<<(char)(ans.front()+'A'-1);ans.pop();}cout<<".";return true;
}
int main(){cin>>n>>m;for(int i=1;i<=m;i++){char s[10];cin>>s;a[i]=mp(s[0]),b[i]=mp(s[2]);}add(a[1],b[1]);if(a[1]==b[1]){cout<<"Inconsistency found after 1 relations.";return 0;}vis[a[1]]=vis[b[1]]=true;c1=2;for(int i=2;i<=m;i++){if(a[i]==b[i]){cout<<"Inconsistency found after "<<i<<" relations.";return 0;}if(!vis[a[i]]) vis[a[i]]=true,c1++;if(!vis[b[i]]) vis[b[i]]=true,c1++;add(a[i],b[i]);if(Toposort(i)) return 0;}cout<<"Sorted sequence cannot be determined.";return 0;
}
luogu P6145 [USACO20FEB] Timeline G
这就是使用 超级源点 把 节点信息变成边权 的一道题(自己认为是道好题)
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
const int N=1e5+5;
int n,m,C,cnt,hd[N],nxt[N<<1],to[N<<1],w[N<<1],dis[N],d[N];
void add(int a,int b,int c){nxt[++cnt]=hd[a];hd[a]=cnt;to[cnt]=b;w[cnt]=c;d[b]++;
}
void Toposort(int s){queue<int>q;q.push(s);dis[s]=0;while(!q.empty()){int u=q.front();q.pop();for(int i=hd[u];i;i=nxt[i]){int t=to[i];dis[t]=max(dis[t],dis[u]+w[i]);if(--d[t]==0) q.push(t);}}
}
int main(){cin>>n>>m>>C;int S=n+1;for(int i=1;i<=n;i++){int k;cin>>k;add(S,i,k);//用超级源点把节点信息变成边权超级源点和该节点的边权}for(int i=1;i<=C;i++){int a,b,c;cin>>a>>b>>c;add(a,b,c);}Toposort(S);for(int i=1;i<=n;i++) cout<<dis[i]<<endl;return 0;
}
继续更新:
今日2025.10.20
又被侯老师叫去做图论题了,不过正好,遇到了一类以前不会的题:
luogu P1983 [NOIP 2013 普及组] 车站分级
题目自己看就好了,这里说思路:
我们只需把高等级的车站向低等级的车站建一条有向边,然后跑拓扑排序就好,就是一个 分层 ,车站等级开个数组 \(dis[n]\) 记录即可,以后输出最大的 \(dis\) 就是答案。
但是,没说完,这题 建边有优化,不优化可能会 \(TLE\) 第 \(7\) 个点(侯老师因为这个一直在调,疑似失心疯)。你会发现,对于某一条路线,如果始发站为 \(s\),终点站 \(t\),这之间输入了 \(c\) 个车站,如果 暴力建边,让每个高级站都和低级战建一条边,会建 \(c*(t-s+1-c)\) 条边,为 \(O(n^2)\) 级别,有点大。所以我们用 虚拟点优化 (就是在高级站和低级站之间建立一个 中转站 ),这样,所有高级站向中转站分别建一条有向边,再由中转站向每个低级站分别建一条有向边,即可达到原来的效果。此时,只需建 $c+(s-t+1-c)=s-t+1 $ 条边,优化到了 \(O(n)\) ,但记住,虚拟点不要算在等级内。
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=2005;
int n,m,cnt,hd[N],nxt[N*N],to[N*N],d[N],a[N],dis[N];//dis:车站等级
bool vis[N];
void add(int a,int b){nxt[++cnt]=hd[a];hd[a]=cnt;to[cnt]=b;d[b]++;//入度+1
}
void Toposort(int s){queue<int>q;q.push(s);dis[s]=0;//超级源点等级为0while(!q.empty()){int u=q.front();q.pop();for(int i=hd[u];i;i=nxt[i]){int t=to[i];if(t>n) dis[t]=max(dis[t],dis[u]);//特判,节点编号>n,说明是虚拟中转站,等级不加1else dis[t]=max(dis[t],dis[u]+1);if(--d[t]==0) q.push(t);}}
}
int main(){cin>>n>>m;int S=n+m+1;//超级源点for(int i=1;i<=m;i++){int k,s,t;cin>>k>>s;add(s,n+i);//n+i 第i个是虚拟点,高级站向中转站建边memset(vis,0,sizeof(vis));vis[s]=true;for(int j=2;j<=k;j++){cin>>t;vis[t]=true;add(t,n+i);}for(int j=s+1;j<t;j++) if(!vis[j]) add(n+i,j);//中转站向低级战建边}for(int i=1;i<=n;i++) if(d[i]==0) add(S,i);//初始入度为0的,成为超级源点的儿子Toposort(S);int ans=0;for(int i=1;i<=n;i++) ans=max(ans,dis[i]);cout<<ans;return 0;
}
到现在为止,侯老师还在调,目前 \(\#7\) 是 \(1.03s\),额不对,最新消息,调成全 \(WA\) 了(笑死我了)