这篇文章总结图论算法中较基础的拓扑排序问题,所谓拓扑排序就是在有向无环图中,一种保证可以从底到顶(沿着箭头方向)遍历所有节点的一种顺序,可以由此解决很多问题。
首先讲一讲拓扑排序的模板,思路就是在建图的时候同时维护一个入读表,也就是有多少箭头指向该节点,当一个节点的入度是零的时候也就说明当前节点属于最底层的节点,需要被输出,而每输出一个节点,我们就把该节点指向的所有节点入度-1,动态更新入度表,直到所有节点都输出,也就是所有节点的入读都变成零。
可以证明,如果一个有环图是不可能最终所有节点入读是0的,比如说1->2->3->1,此时三者入读都是1,无法输出任何一个节点,将陷入死循环,这也是为什么拓扑排序强调必须是有向无环图,否则将陷入死循环。因此拓扑排序也可以来判断有向图是否有环,在任意时刻如果没有任何一个点入度是零那么一定有环存在。
同时我们注意,拓扑排序并不是只有一种,因为同时为零的点可能有若干个,在没有特别要求的情况下,我们称任何一种方案都是正确的拓扑序。
模板题:207.课程表
你这个学期必须选修 numCourses
门课程,记为 0
到 numCourses - 1
。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites
给出,其中 prerequisites[i] = [ai, bi]
,表示如果要学习课程 ai
则 必须 先学习课程 bi
。
- 例如,先修课程对
[0, 1]
表示:想要学习课程0
,你需要先完成课程1
。
请你判断是否可能完成所有课程的学习?如果可以,返回 true
;否则,返回 false
。
这道题就是纯粹的模板的拓扑排序题,首先我们分析问题,课程有依赖的关系,必须满足修了先修课程才能修后续的课程,所以必须有一种顺序,当你修当前课程的时候必须保证他的所有先修课程必须已经修过了,也就是来到一个节点的时候必须保证他的前序节点都已经走过了,也就是拓扑排序,那么我们先用邻接表建图来解决这个问题。
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> graph(numCourses);//邻接表建图
vector<int> indegree(numCourses);//入读表
vector<int> quene(numCourses+1);//数组实现队列
int n=prerequisites.size();
for(int i=0;i<n;i++){
graph[prerequisites[i][1]].push_back(prerequisites[i][0]);
indegree[prerequisites[i][0]]++;
}
int l=0,r=0;
for(int i=0;i<numCourses;i++){
if(indegree[i]==0){
quene[r++]=i;
}
}
int cnt=r;
while(l<r){//队列不为空时
int cur=quene[l++];
for(auto i=graph[cur].begin();i!=graph[cur].end();i++){
indegree[*i]--;
if(indegree[*i]==0){
quene[r++]=*i;
}
}
}
return r==numCourses;
}
};
代码有一些细节需要注意,首先是建图注意同时要维护入读表,用于后续更新,同时我们需要一个队列(本题用数组实现),队列中的元素就代表已经走过的元素,我们每次弹出一个节点,并把他指向的节点的入度更新,如果新发现一个入度为零的点就加入队列中,直到l>=r也就是队列为空的时候说明当前不再有节点入读为零,这时候我们检查队列中元素数目是不是等于课程数量,如果等于说明一定存在拓扑序的解。
上篇讲了建图的方式但是没有进行实践,所以对于这道题再给出链式前向星的解法(只有建图有区别,其他思路完全一样)
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<int> head(numCourses);
vector<int> next(5005);
vector<int> to(5005);
vector<int> indegree(numCourses);//入读表
vector<int> quene(numCourses+1);//数组实现队列
int cnt=1;
int n=prerequisites.size();
for(int i=0;i<n;i++){
next[cnt]=head[prerequisites[i][1]];
to[cnt]=prerequisites[i][0];
head[prerequisites[i][1]]=cnt++;
indegree[prerequisites[i][0]]++;
}
int l=0,r=0;
for(int i=0;i<numCourses;i++){
if(indegree[i]==0){
quene[r++]=i;
}
}
while(l<r){//队列不为空时
int cur=quene[l++];
for(int i=head[cur];i!=0;i=next[i]){
indegree[to[i]]--;
if(indegree[to[i]]==0){
quene[r++]=to[i];
}
}
}
return r==numCourses;
}
};
简单重复一下上一篇文章的内容,首先head下标代表起点节点,head值代表邻接边链条的头边,next[a]=b代表a号边结束后是b号边,b=0的时候说明到了最后一条边,to下标代表边编号,值代表终点,详情看上一篇。
LCR.114.火星词典
现有一种使用英语字母的外星文语言,这门语言的字母顺序与英语顺序不同。
给定一个字符串列表 words
,作为这门语言的词典,words
中的字符串已经 按这门新语言的字母顺序进行了排序 。
请你根据该词典还原出此语言中已知的字母顺序,并 按字母递增顺序 排列。若不存在合法字母顺序,返回 ""
。若存在多种可能的合法字母顺序,返回其中 任意一种 顺序即可。
字符串 s
字典顺序小于 字符串 t
有两种情况:
- 在第一个不同字母处,如果
s
中的字母在这门外星语言的字母顺序中位于t
中字母之前,那么s
的字典顺序小于t
。 - 如果前面
min(s.length, t.length)
字母都相同,那么s.length < t.length
时,s
的字典顺序也小于t
。
这道题主要思路就是去模拟字典的排序,首先比较最靠前的不同的字符,然后如果最前面几个全部相同那么较短的一个字符串字典序更小,所以我们就模拟这个过程,在每个具有比较关系的位置进行建图,让较小的字符指向更大的字符,最后进行拓扑排序即可,需要注意的是,我们要判断无解的情况,首先如果建图有环就是无解(具体方法和上题相同),或者如果出现前面几个字符都相同,最后却较长的字符串在前面,此时也无解,其余就是模板化的拓扑排序。
class Solution {
public:
string alienOrder(vector<string>& words) {
int n=words.size();
vector<int> in(30);
vector<vector<int>> graph(26);
vector<int> quene(30);
string ans;
unordered_set<char> st;
for(int i=0;i<n;i++){
int len1=words[i].size();
for(int w=0;w<len1;w++){
if(!st.count(words[i][w])) st.insert(words[i][w]);
}
for(int j=i+1;j<n;j++){
int len2=words[j].size();
bool flag=false;
for(int k=0;k<len1&&k<len2;k++){
if(words[i][k]!=words[j][k]){
in[words[j][k]-'a']++;
graph[words[i][k]-'a'].push_back(words[j][k]-'a');
flag=true;
break;
}
}
if(!flag&&len1>len2) return "";
}
}
int l=0,r=0;
for(int i=0;i<26;i++){
if(st.count(i+'a')){
if(in[i]==0){
quene[r++]=i;
}
}
}
int kinds=st.size();
while(l<r){
int cur=quene[l++];
ans.push_back(cur+'a');
for(auto it=graph[cur].begin();it!=graph[cur].end();it++){
if(--in[*it]==0){
quene[r++]=*it;
}
}
}
return ans.size()==kinds?ans:"";
}
};
936.戳印序列
你想要用小写字母组成一个目标字符串 target
。
开始的时候,序列由 target.length
个 '?'
记号组成。而你有一个小写字母印章 stamp
。
在每个回合,你可以将印章放在序列上,并将序列中的每个字母替换为印章上的相应字母。你最多可以进行 10 * target.length
个回合。
举个例子,如果初始序列为 "?????",而你的印章 stamp
是 "abc"
,那么在第一回合,你可以得到 "abc??"、"?abc?"、"??abc"。(请注意,印章必须完全包含在序列的边界内才能盖下去。)
如果可以印出序列,那么返回一个数组,该数组由每个回合中被印下的最左边字母的索引组成。如果不能印出序列,就返回一个空数组。
例如,如果序列是 "ababc",印章是 "abc"
,那么我们就可以返回与操作 "?????" -> "abc??" -> "ababc" 相对应的答案 [0, 2]
;
另外,如果可以印出序列,那么需要保证可以在 10 * target.length
个回合内完成。任何超过此数字的答案将不被接受。
这道题确实是相当有难度的一道题,哪怕我看了题解也思考了半天,所以是一道很有味道的题目,值得去多学习几遍,首先我们把盖印章的过程理解为修正的一个过程,也就是我们盖印章就是为了掩盖之前盖过的印章的不正确的部分,那么很自然的我们想到,错的越多的印章那么就需要越多次的其他位置的印章来掩盖错误,而对于一个已经被完全修正的印章位置,也就是盖下去后保证可以被后续的印章修正,我们就可以盖下去了,所以这就是一个拓扑排序的问题,每一个盖下去的印章必须保证后续的印章可以修复他,那我们按照正常思路确定一个盖下去的印章,又不知道后续的拓扑序如何,又怎么可能保证这个决策合理呢,所以就考虑倒序来做,也就是从最后一个盖下去的开始排一个拓扑序,那么这时候的拓扑序就代表,每盖下一个位置的印章,保证他已经被前面的印章所修改完成了(虽然实际上是在他后面盖的),我们拿一个具体的印章序列来看,如果他有n个位置是盖下stamp后的标准序列不同的位置(也就是我们上文说的“错误”),那么在这n个错误被修正之前,他不能盖下,也就是说这n个错误就是他的入度,让错误位置指向他对应的作为错误的印章位置,(比如说2位置的以1位置为第一个位置的印章序列中错误的一个位置,那么我们就让2->1),注意辨析此时图的起点表示错误的位置也就是字符位置,但是终点表示一个印章位置。
那么确定了入度是什么和拓扑序的意义,我们就可以在建图的时候统计一个印章位置的错误,就是相对这个位置盖上标准印章序列时和最终不同的位置,然后在队列中弹出的时候,因为这个位置弹出可以为后续位置修改一些位置,遍历这个位置印章的所有涵盖的位置的邻接位置,是否可以替其他位置修正一些错误,如果可以,将对应位置的入读--,最后需要翻转ans数组,因为我们是按照倒叙的思路去考虑的,如果不能将全体形成拓扑序,说明无解,否则就返回ans,大体思路如此,先看代码。
class Solution {
public:
vector<int> movesToStamp(string stamp, string target) {
int n=stamp.size();
int m=target.size();
vector<int> ans;
vector<int> anss;
vector<int> in(m-n+1);
vector<bool> visited(m);
vector<int> quene(m-n+1);
vector<vector<int>> graph(m);
for(int i=0;i<m-n+1;i++){
for(int j=0;j<n;j++){
if(stamp[j]!=target[i+j]){
in[i]++;
graph[i+j].push_back(i);
}
}
}
int l=0,r=0;
for(int i=0;i<m-n+1;i++){
if(in[i]==0) quene[r++]=i;
}
while(l<r){
int cur=quene[l++];
ans.push_back(cur);
for(int j=0;j<n;j++){
if(!visited[cur+j]){
visited[cur+j]=true;
for(auto it=graph[cur+j].begin();it!=graph[cur+j].end();it++){
if(--in[*it]==0){
quene[r++]=*it;
}
}
}
}
}
reverse(ans.begin(),ans.end());
return ans.size()==m-n+1?ans:anss;
}
};
一些代码细节,首先是一个visited数组,一开始自己做就是卡在这里,一直报错,后来看了题解才考虑到,如果一个位置被重复考虑,那么会让入度变成负数,所以我们修正一个位置来走一次就够了,不要重复判断。此外看网络题解中采用一开始把入读数组中值都初始化为印章长度,然后找到正确的就--,错误就不动,这样可以在建图的时候就把入读是0的位置压入队列,十分巧妙,但是我懒得改了hhh,学习这种巧妙的思路,主动的去来到入读是0的位置方便判断压入队列,而不是事后遍历去找离开0位置的元素,巧妙。
洛谷P4017 最大食物链计数
你知道食物链吗?Delia 生物考试的时候,数食物链条数的题目全都错了,因为她总是重复数了几条或漏掉了几条。于是她来就来求助你,然而你也不会啊!写一个程序来帮帮她吧。
题目描述
给你一个食物网,你要求出这个食物网中最大食物链的数量。
(这里的“最大食物链”,指的是生物学意义上的食物链,即最左端是不会捕食其他生物的生产者,最右端是不会被其他生物捕食的消费者。)
Delia 非常急,所以你只有 11 秒的时间。
由于这个结果可能过大,你只需要输出总数模上 8011200280112002 的结果。
通过这道题,我们要认识到,拓扑排序的作用不止是排序后的结果本身,拓扑到后续也可以为后续节点带来前方的信息,也就是一种类似动态规划的感觉,也就是简单的树形dp,首先我们不难想到dp的思路,也就是本位置的食物链条数取决于所有可以来到这个位置的条数值的总和,然后一直更新到所有的节点都被遍历之后,所有最高级消费者节点的值的总和就是最终答案,但是问题在于更新的顺序,也就是我们更新这个位置的时候,必须保证他所依赖的所有位置都已经求出了值,也就是所有指向它的节点,这恰好就是拓扑排序,所以我们更新dp表的顺序恰好就是拓扑排序的顺序,更新dp表后需要注意的是,我们要直到哪些位置是最高级消费者,必须关注该节点出度是否为零,也就是head数组中对应值是0代表没有邻边,将所有出度为零的点的值求总和即可。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
void solve(){
int n,m;
int mod=80112002;
cin>>n>>m;
vector<int> head(n+1);
vector<int> next(m+1);
vector<int> to(m+1);
vector<int> dp(n+1);
vector<int> in(n+1);
vector<int> quene(n+1);
int l,r;
for(int i=1;i<=m;i++){//链式前向星建图,邻接表会爆掉
cin>>l>>r;
next[i]=head[l];
to[i]=r;
head[l]=i;
in[r]++;
}
l=0,r=0;
for(int i=1;i<=n;i++){
if(in[i]==0) quene[r++]=i,dp[i]=1;
}
int ans=0;
while(l<r){
int cur=quene[l++];
if(head[cur]==0){
ans=(ans+dp[cur])%mod;
}else{
for(int ed=head[cur];ed!=0;ed=next[ed]){
dp[to[ed]]=(dp[to[ed]]+dp[cur])%mod;
if(--in[to[ed]]==0) quene[r++]=to[ed];
}
}
}
cout<<ans<<endl;
}
int main()
{
solve();
return 0;
}
851.喧闹和富有
有一组 n
个人作为实验对象,从 0
到 n - 1
编号,其中每个人都有不同数目的钱,以及不同程度的安静值(quietness)。为了方便起见,我们将编号为 x
的人简称为 "person x
"。
给你一个数组 richer
,其中 richer[i] = [ai, bi]
表示 person ai
比 person bi
更有钱。另给你一个整数数组 quiet
,其中 quiet[i]
是 person i
的安静值。richer
中所给出的数据 逻辑自洽(也就是说,在 person x
比 person y
更有钱的同时,不会出现 person y
比 person x
更有钱的情况 )。
现在,返回一个整数数组 answer
作为答案,其中 answer[x] = y
的前提是,在所有拥有的钱肯定不少于 person x
的人中,person y
是最不安静的人(也就是安静值 quiet[y]
最小的人)。
一开始我有一个错误的思路,就是按照拓扑排序走一遍的过程中随时维护走过的最小值,但是注意审题之后发现,他说肯定不少于,那么说明有的人rich关系是无法判断的,那么什么时候是肯定可以决定谁更富有呢,简单画图就可以发现,如果两个人之间有一条类似食物链的结构,才能肯定的判断财富关系,所以动态维护最小值是在这样一个链上维护,所以树形dp的依赖关系就是每个位置依赖所有能跟他成链的位置的关系,归根到底其实也就是依赖于所有指向它的点,那么其实就是按照拓扑序更新,跟上一题有异曲同工之妙。
class Solution {
public:
vector<int> loudAndRich(vector<vector<int>>& richer, vector<int>& quiet) {
int n=quiet.size();
int m=richer.size();
vector<vector<int>> graph(n);
vector<int> in(n);
vector<int> quene(n);
vector<int> ans(n);
for(int i=0;i<n;i++) ans[i]=i;
vector<int> a(2);
for(int i=0;i<m;i++){
graph[richer[i][0]].push_back(richer[i][1]);
in[richer[i][1]]++;
}
int l=0,r=0;
for(int i=0;i<n;i++){
if(in[i]==0) quene[r++]=i;
}
while(l<r){
int cur=quene[l++];
for(auto it=graph[cur].begin();it!=graph[cur].end();it++){
if(quiet[ans[*it]]>quiet[ans[cur]]){
ans[*it]=ans[cur];
}
if(--in[*it]==0){
quene[r++]=*it;
}
}
}
return ans;
}
};
2050.并行课程
给你一个整数 n
,表示有 n
节课,课程编号从 1
到 n
。同时给你一个二维整数数组 relations
,其中 relations[j] = [prevCoursej, nextCoursej]
,表示课程 prevCoursej
必须在课程 nextCoursej
之前 完成(先修课的关系)。同时给你一个下标从 0 开始的整数数组 time
,其中 time[i]
表示完成第 (i+1)
门课程需要花费的 月份 数。
请你根据以下规则算出完成所有课程所需要的 最少 月份数:
- 如果一门课的所有先修课都已经完成,你可以在 任意 时间开始这门课程。
- 你可以 同时 上 任意门课程 。
请你返回完成所有课程所需要的 最少 月份数。
注意:测试数据保证一定可以完成所有课程(也就是先修课的关系构成一个有向无环图)。
这道题只需要理解题意即可,其余就是简单的树形dp,首先说可以同时进行,也就是所有当前可以修的课全部修完需要的时间就是先修课的个体最大值,先修课结束后才能修下一级的课,所以依赖关系就是,当前位置的dp值依赖所有前序节点的dp值加上当前位置时间的最大值,其余细节看代码。
class Solution {
public:
int minimumTime(int n, vector<vector<int>>& relations, vector<int>& time) {
vector<vector<int>> graph(n+1);
vector<int> queue(n+1);
vector<int> in(n+1);
vector<int> dp(n+1);
int ans=-1;
int m=relations.size();
for(int i=0;i<m;i++){
graph[relations[i][0]].push_back(relations[i][1]);
in[relations[i][1]]++;
}
int l=0,r=0;
for(int i=1;i<=n;i++){
if(in[i]==0) queue[r++]=i;
dp[i]=time[i-1];
ans=max(ans,time[i-1]);
}
while(l<r){
int cur=queue[l++];
for(auto it=graph[cur].begin();it!=graph[cur].end();it++){
//cout<<dp[*it]<<endl;
dp[*it]=max(dp[*it],dp[cur]+time[*it-1]);
ans=max(ans,dp[*it]);
if(--in[*it]==0) queue[r++]=*it;
}
}
return ans;
}
};
拓扑排序是图论中基础而重要的一环,本篇旨在从模板到进阶再到在树形dp中的使用,在遇见问题并不存在线性的依赖关系且先后顺序影响问题的答案的时候我们就可以考虑用拓扑排序的思路去解决。
至此。