算法基础系列第三章——万字精编手把手教你壁咚拓扑排序,让ta乖乖听话~

前言:为学日进,为道日损。与诸君携手共勉 💖💖💖

婉儿

🔔目录

💓背景引入

倘若我们把施工过程、生产流程、软件开发等都当成一个项目工程来对待,那么所有工程都可分为若干个子工结合而成。这些子工程之间通常会受到一定的约束,如其中一些子工程是必须在另外一些子工程完成以后才能开始。
举个栗子

电影制作过程中,必须要先有场地,再有导演组织演员进行拍摄。将这些零零散散的关系链接起来,形成的就是一个有向无环图。无环就是没有回路。可能有小伙伴想问,为什么一定无环了?
有环

如图中的两个子工程a,b假如有环,a一定要先于b完成,但是因为有环,b是指向a的,b也一定要先于a完成,就矛盾了。

当图的关系过于庞大和复杂,我们要获得一条该如何进行子工程的流程图时,拓扑排序就可以大展身手
大展身手

进入下一个模块啦~⏩
🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀

💓前戏——图的遍历

✅图的宽度优先搜索

图的宽度优先搜索
🍹原题传送门

题中要求最短路径,并且也指出了,每条边的的长度都是为1,即权重相等。宽度优先搜索BFS有没有在你的脑海中冒出来了

⭐参考代码(C++版本)

#include <iostream>
#include <cstring>

using namespace std;
const int N  = 100010;
int n,m;
int h[N],e[N],ne[N],idx;//实现静态链表所需要的数组
int q[N],d[N];//bfs所需的队列和距离数组

void add(int a,int b)
{
    e[idx] = b;
    ne[idx] = h[a];
    h[a] = idx ++;
}

int bfs()
{
    
    int hh  = 0,tt = 0;//定义队头和队尾
    //初始化距离数组
    memset(d,-1,sizeof d);
    //入队1号
    q[0] = 1;
    //标记1已被探索
    d[1] = 0;
    
    //当队列不空
    while(hh <= tt)
    {
        //取队头
        int t  = q[hh ++];
        //拓展队头
        for(int i = h[t] ; i != -1;i = ne[i])
        {
            int j = e[i];
            if(d[j] == -1) 
            {
                d[j] = d[t]+1;//标记这个点走过了
                //将这个拓展的点入队
                q[++ tt]  = j;
            }
        }
    }
    
    return d[n];
}



int main()
{
    cin >> n >> m;
    
    //初始化邻接表,让表头指向空(静态链表中常用-1表示空)
    memset (h,-1,sizeof h);
    
    //录入数据,并将它们连通为图
    for(int i = 0;i < m;i++)
    {
        int a, b;
        cin >>a >>b;
        add(a,b);
    }
    cout << bfs() << endl;
    
    return 0;
}

图BFS的AC
快乐AC~
快乐

浏览完代码的小伙伴应该会感觉,和我之前博客中写的BFS大同小异的嘛,🐶东西你水我们呀💢💢💢

因为BFS是一种框架或者说是一种算法模板,掌握了就可以反复套娃了。

反复套娃

图常用的表示方式有邻接矩阵和邻接表,因为邻接矩阵适合高密度的数据,所以一般是采用静态链表构造邻接表表示图。
对BFS框架和静态链表不熟悉的小伙伴可以先去看看我之前写的文章喔~🌹🌹🌹

🍻数据结构——静态链表
🍻算法基础系列第三章——层层推进的BFS

✅图的胶合剂——add()

add函数的作用是将新的点连接到邻接表上。

进入下一个模块⏩
🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀

💓拓扑排序

✅典例

有向图的拓扑序列
🍹原题传送门

⭐参考代码(C++版本)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;
const int N  = 100010;
int n,m;
int h[N],e[N],ne[N],idx;
int q[N],d[N];//表示队列和度


void add(int a,int b)
{
    e[idx] = b;
    ne[idx] = h[a];
    h[a] = idx ++;
}

bool toposort()
{
    int hh = 0,tt = -1;//定义队列的队头hh 和 队尾tt
    //把入度为0的点全部入队
    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 != -1; i  = ne[i])
        {
            int j = e[i];
            d[j] --;
            if(!d[j]) q[++ tt] = j;
        }
    }
    return tt == n-1; //当尾指针迭代到最后一个数据了,证明形成一个拓扑序列了
}

int main()
{
    cin >>n >>m;
    memset(h,-1,sizeof h);
    
    //接下来 m 行,每行包含两个整数 x 和 y,表示存在一条从点 x 到点 y 的有向边 (x,y)。
    for(int i = 0; i < m;i++)
    {
        int a, b;
        cin >> a >> b;
        add(a,b);
        d[b] ++;//因为是将b接在a的后面,所以b的入度得增加
    }
    
    if(toposort())
    {
        //输出一个拓扑排序
        for(int i = 0; i <n;i++) printf("%d ",q[i]);
        puts("");
    }else
    {
        puts("-1");
    }
    
    return 0;
}

有向图拓扑序列的AC

✅拓扑序列实现框架

  1. 使用cstring中的库函数memset快速初始化邻接表
		memset(h,-1,sizeof h);
  1. 应题意将数据连接,形成一个图,连接过程中,注意调整各点的入度(入度是指向这个结点的边数)
		add(a,b);
		d[b] ++;//因为是将b接在a的后面,所以b的入度要增加
  1. 将所有入度为0的点放入队列
		 for(int i = 1;i <= n;i++)
		    if(!d[i]) q[++ tt] = i;
  1. 当队列不空:取出队头元素,拓展队头
		//拓展队头
		for(int i = h[t]; i != -1; i  = ne[i])
		    {
		        int j = e[i];
		         d[j] --;
		        if(!d[j]) q[++ tt] = j;
		    }

✅疑点剖析

相较于原本的BFS框架而言,变化较大的是拓展队头这块的逻辑

在迷宫问题中是对四个方向进行拓展,在拓扑序列中,是对这个队头t所在的邻接表进行拓展。

  1. 将队头t所在的邻接表的头指针的地址(本质是数组的下标)赋值给i,当指针i没有指向空,进入循环,更新循环变量i,使其指向它的后一位
		for(int i = h[t]; i != -1; i  = ne[i])
  1. 获取拓展信息的具体数值。e[N]中存储的是每个结点的数值。通过拓展获得的下标i放到e[N]中就可以匹配到具体的数值了。再将这个数值的度减1
		int j = e[i];
		 d[j] --;
  1. 倘若减1之后,入度为0。就符合进入队列的标准,将其入对
		if(!d[j]) q[++ tt] = j;

学会了,进入下一个模块⏩
🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀

💓举一反三

草

✅一、家谱树——信息学奥赛一本通-T1351

家谱树
🍹原题传送门

⭐参考代码(C++版本)

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;
const int N  = 550;
int n;
int h[N],e[N],ne[N],idx;
int q[N],d[N];

void add(int a,int b)
{
    e[idx] = b;
    ne[idx] = h[a];
    h[a] = idx ++;
}

void toposort()
{
    int hh = 0,tt = -1;
    //先把所有度为0的点入队
    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 != -1;i  = ne[i])
        {
            int j = e[i];
            d[j]--;
            if(!d[j]) q[++ tt]  = j;
        }
    }
}

int main()
{
    cin >> n;
    memset(h,-1,sizeof h);
    for(int i = 1; i <= n;i++)
    {
        int a;
        while( cin >> a, a!= 0 )
        {
            add(i,a);
            d[a] ++;
        }
    }
    
    toposort();
    
    for(int i = 0; i <n;i++) printf("%d ",q[i]);
    return 0;
}
🌻样例剖析

家谱树就和我们上文的典例几乎一模一样了,换汤不换药。

一个拓扑序列就正好满足题目要求的输出序列。

🌻小总结

当题目中给了杂乱的关系图,最后让输出一份有顺序的序列,记得可以用拓扑排序实现
糖

✅二、奖金——信息学奥赛一本通-T1352

奖金
🍹原题传送门

⭐参考代码(C++版本)

#include <iostream>
#include <cstring>


using namespace std;
const int N  = 20010;
int n,m;
int h[N],e[N],ne[N],idx;
int q[N],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 != -1; i = ne[i])
        {
            int j = e[i];
            d[j] --;
            if(!d[j])
            {
                q[++ tt] = j;
            }
        }
    }
    return tt == n-1;//tt 是尾指针,等于n-1说明有n个点形成一个拓扑序列了,否则就有环
}

int main()
{
    
    cin >>n >>m;
    memset(h,-1,sizeof h);
    
    while(m--)
    {
        int a,b;
        cin >> 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);//1是增加的最小奖金增量,此题也可以看做权重
        }
        
        int res = 0;
        for(int i = 1;i <= n;i++) res += dist[i];
        
        cout << res << endl;
        
    }
    
    return 0;
}
🌻样例剖析

奖金这道题就不再是简单暴力的直接求拓扑序列了,这里要对求出来的拓扑序列作为已知条件,进而求解出答案。
不简单

🌻难点一:构建图的细节
        int a,b;
        cin >> a >>b;
        add(b,a);
        d[a] ++;

题干要求是为员工 a 的奖金应该比 b 高。处理为让a接在b后面,也就是增加a的入度,这种让奖金被最加的最高放到后面,在计算的时候就会更方便
指向

🌻难点二:计算最长路

可能有小伙伴会疑问,题目要求我们求最省钱的方式,你为什么要求最长路了?思想和高中数学的解析几何求最值很像似。一个开头向下的二次函数,假如在顶点算出来的值是可以满足,那其他点也肯定满足。同理,让最糟糕的时候都满足最省钱的方案,那么其他的情况也肯定能满足

操作流程:

取出存在队列中的每一个数据元素。

    int j = q[i];//取出队列中存的元素

放到邻接表中,求得这个元素的下一位元素

    for(int k = h[j] ; ~k; k = ne[k])

计算下一位的奖金

    dist[e[k]] = max(dist[e[k]],dist[j] +1)

因为下一位员工比当前这个员工奖金高,又为了省钱,所以用dist[j] +1元表示下一位员工可获得的奖金

求出总和,快乐Ac~

    int res = 0;
    for(int i = 1;i <= n;i++) res += dist[i];

	 cout << res << endl;

起飞

✅三、可达性统计——算法竞赛进阶指南

可达性问题
🍹原题传送门

⭐参考代码(C++版本)

#include <iostream>
#include <cstring>
#include <algorithm>
#include <bitset>

using namespace std;
const int N  = 30010;
int n ,m;
int h[N],e[N],ne[N],idx;//数组模拟的邻接表
int q[N],d[N];//队列和度
bitset<N> f[N];//f[i]表示这个点可以达到的集合,这里使用二进制压位,二进制压位的规则是1是可以到达,0是不能到达

void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a],h[a] = idx ++;
}

void topsort()
{
    int hh = 0, tt = -1;
    
    //把度为0的点入队
    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 != -1;i = ne[i])
        {
            int j = e[i];
            if(--d[j] == 0) q[++ tt] = j;
        }
    }
}

int main()
{
    ios::sync_with_stdio(false);
    
    cin >> n >> m;
    
    memset(h,-1,sizeof h);//初始化邻接表
    
    while(m--)
    {
        int a ,b;
        cin >>a >>b;
        add(a,b);
        d[b] ++;
    }
    
    //利用拓扑序列的性质实现倒推回去,那么此时我需要做的就是压位
    topsort();
    
    //从拓扑序列中取出每一个点
    for(int i = n-1;i >= 0;i--)
    {
        int j = q[i];
        f[j][j] = 1;//表示j可以到j这个点,因为对于二进制的而言,要么就是[j-0]表示不能到达,要么就[j-1]能到达
        
        for(int k = h[j] ; k != -1;k = ne[k])
            f[j] |= f[e[k]];//把j能到的点和j之后的点能到的点通过或运算合并在一起
    }
    
    for(int i = 1; i <= n;i++) cout << f[i].count() <<endl;
    
    return 0;
}

脑瓜疼

🌻样例剖析

这道题是拓扑排序的再深化了。拓扑排序在这里只是被利用起来的一步工具,需要使用它获得一个拓扑序列,然后我们从拓扑序列的尾端递推算回去每个点可以到达的点的数量

🌻逐步讲解

一、

头文件将需要用的一些声明呀、数据呀,噼里啪啦的敲上来

#include <iostream>
#include <cstring>
#include <algorithm>
#include <bitset>

using namespace std;
const int N  = 30010;
int n ,m;
int h[N],e[N],ne[N],idx;//数组模拟的邻接表
int q[N],d[N];//队列和度
bitset<N> f[N];//f[i]表示这个点可以达到的集合,这里使用二进制压位,二进制压位的规则是1是可以到达,0是不能到达

需要介绍STL容器中的bitset,简单来说,它能将传入的一个无符号数转成一个二进制的数列。构造时,需在<>中表明bitset 的大小(即size)。

因为数据比较大,此时记得算一下规模,防止出现TLE或者MLE。

假如直接递推回来,就需要大约要M * M (题干 中说M<=30000)的内存,假如拉到极限,30000*30000个int,1e6个int 是4M,因此会超出限定的256MB

考虑采用二进制实现状态压缩,一个int可以转成32位的二进制,即一个int就可以表示32个数,此时再算,大约是要120MB左右,符合要求了。

震撼

二、

书写求拓扑序列的框架

memset(h,-1,sizeof h);//初始化邻接表
    
    while(m--)
    {
        int a ,b;
        cin >>a >>b;
        add(a,b);
        d[b] ++;
    }
    
    //利用拓扑序列的性质实现倒推回去
    topsort();

三、

取出拓扑序列中的每个点,放到存放i这个点能到达的点的数组f[i]。结合"或运算"实现"并上"的功能,求它能到达的点的个数
并上

    //从倒着拓扑序列中取出每一个点
    for(int i = n-1;i >= 0;i--)
    {
        int j = q[i];
        f[j][j] = 1;

        for(int k = h[j] ; k != -1;k = ne[k])
            f[j] |= f[e[k]];
    }

    for(int i = 1; i <= n;i++) cout << f[i].count() <<endl;

位运算状态压缩的规则是0表示不能到达,1表示可以到达。
简单来说,就是以前的每一个数,比如int型的数值2999现在可以用一个位二进制表示了,结合拓扑图求出来的序列,最后算出来某一点可以到达的点的数量

f[j][j]表示现在获取的这个数据是能到达它自身,而对于之后的点,则是由0和1表示能不能到到达
AC
看到这里,相信诸位都已经壁咚上了拓扑排序这位小娇妻了吧,那我关灯啦~
关灯

🔔总结

拓扑排序在算法竞赛中很少直接出裸题让我们直接把分薅到手,正如我逐渐逐渐带入的例题一样,拓扑排序往往只会作为一条件加以利用,就像高考数学解析几何第一小问就是求曲线方程,第二问把第一问作为条件。所以道阻且长呀~,与诸君共同努力🌹🌹🌹

谢谢耐心观看啦~,若有偏颇,欢迎及时指出喔💖💖💖

基础算法持续更新中ing~

武则天

  • 26
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 35
    评论
评论 35
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杨枝

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值