两类特殊的图(有向无环图+树)
本蒟蒻又来发blog了啦~
有向无环图
有向无环图有成为 DAG(Database Availability Group) ,顾名思义,就是指没有换的有向图
有向无环图虽然定义很简单,很朴素,但是具有良好的性质:
因为图中不存在环,因此DAG可以呈现出明显的层级关系
即某个点的层级一定比其他点要高
例如对于一个带环的图,没有办法分出每个点的层级
但对于DAG,就有确定的层次关系
层级:如果\(B\)有一条有向边指向\(A\)
而\(A\)没有一条有向边指向\(B\)
则说\(B\)比\(A\)的层级高
贝叶斯网络
是描述事件的,每一个时间的概率都依赖于其他某些事件,而不存在相互依赖,或者成环率依赖
拓扑排序
对\(DAG\)进行分层分级的常用方式是拓扑排序(Topsort)
拓扑主要研究物体间的位置关系,而不考虑他们的形状和大小
拓扑排序就是根据一些两个物品之间的顺序排除整个物品集合的顺序,更数学地说,是有一个集合上的偏序得到集合的全序
偏序就是两个点之间的大小关系,全序就是所有点的大小关系
(偏序和全序的概念了解就好)
若点u可以到达点v,那么拓扑排序得到的序列保证u在v的前面

具体做法
若想进行拓扑排序,首先需要统计每个点的入度
开始时,挑选所有入度为\(0\)的点入队
每次从队头选取一个点,遍历此点的所有出边,将出边指向的点的入度减\(1\)
(可以理解为把这条出边删掉,也可以理解为把这个点连带着它的边都删掉了)
如果出边只想的某个点在间\(1\)后为\(0\),则将其入队
如此循环往复,直至队列为空,则得到拓扑排序的序列

代码奉上
struct edg{
int nxt,y;
}e[210000];
int lk[110000],ltp=0;
int nd[110000];//nd是入度
void ist(int x,int y){//邻接链表加入边
e[++ltp]=(edg){lk[x],y};
lk[x]=ltp;
++nd[y];
}
int n,m;//点数和边数
int q[110000],hd=0;//q是队列,hd是队尾
void tpst(){//topsort
for(int k=1;k<=hd;k++)//k相当于队头
{
for(int i=lk[q[k]];i;i=e[i].nxt)//枚举出边
{
--nd[e[i].y];//入度减1
if(!nd[e[i].y])//如果入度减为0就入队
q[++hd]=e[i].y;
}
}
for(int i=1;i<=n;i++)//一般队列长度应该恰好为n
cout<<q[i]<<' ';//最后队列里的就是排好序的
cout<<endl;
}
int main()
{
cin>>n>>m;
int l,r;
for(int i=1;i<=m;i++)
{
cin>>l>>r;
ist(l,r);
}
for(int i=1;i<=n;i++)
{
if(!nd[i])
q[++hd]=i;//入度为0的入队
}
tpst();
return 0;
}
树
树的概念
树的数学定义其实非常简单
一个树指的是就是一个没有环的无向连通图
也就是说从任意一个点开始遍历
后面的路径只会分支,不会再循环回原点
这就构成了分叉的形状
树的定义很简单不过还有一些性质需要留意。
树的性质
-
性质一
如果把树任意一条边删除,那么树会分裂成两个树(即两个连通块)
因为如果删边后没有分裂为两个连通块,那么说明这条边的两个端点还有其他路径可以联通
那么这一条另外的路径加上要删的边,就组成了一个环
而树的定义要求没有环
因此结论成立
-
性质二
一个\(n\)个结点的树一定有\(n-1\)条边
证明这个结论可以先把边都删掉,然后逐一加入
一开始没有边的时候,总有n个连通块,每个连通块一个点
每当加入一条边,由性质\(1\)知道,这条边一定会连接两个连通块(而不是在某个连通块内部减\(1\))
那么把\(n\)个连通块恰好连成一个的边数就是树的边数
这个数量是\(n-1\)
-
性质三
树上任意两点之间只有一条简单路径
(简单路径指每条边最多只经过一次的路径)
这个性质比较显然
因为如果有两条简单路径的话显然就是有环了
-
写在后面
这几个性质一般没什么特殊的用途,而且就算不证明,也是比较直观的,第一眼看上去也不会去怀疑它(
证明看不懂可以直接忽略)但是在研究树问题的时候有很常见,而且题目或者教程是不会刻意指出的了
比如输入树的时候,一般只在输入格式里指出有n-1行,一行一条边,不再说明图的边数是\(m=n-1\)
树的其他概念
-
儿子:此概念常见于有根树,与某个节点由一条边连接的,切层次比此结点大的成为此结点的儿子
-
父亲:(反过来)与某个节点由一条边连接的,切层次比此结点小的成为此结点的父亲
-
祖先:父亲的父亲(的父亲的父亲……)
-
兄弟:同为某个节点的儿子的其他结点叫兄弟
-
孙子、表兄、叔父:一般不使用(
自己可以脑补一下自己家的家谱) -
子树:这个点往下的所有点和边
(so 形象……)

树的存储
树的本质是一个图,所以树的存储本质上和图没有什么太大的区别
由于树作为图非常稀疏(\(n\)个点只有\(n-1\)条边)
所以用邻接矩阵效率很低
而一般涉及到树的题目结点个数都不少(\(n\)最常见为\(1e5\))
所以树一般用邻接链表来存
这个就不详细说了
值得一提的是尽管遍历树的时候只需要单向遍历,但是存图的时候还是要存无向边,因为你不知道两个端点哪个层次高
树的遍历
树的遍历和图的遍历也没有什么区别,都是分为DFS和BFS两种
BFS有逐层扩展的特性,先遍历完树的某一层之后再遍历下一层
有些题可能会遇到,但一般不常用
树的遍历主要是指DFS,先遍历完子树之后再返回上一层的结点,去遍历其他结点
树形Dp基本都是DFS,先遍历完子树,得到子树所有结点的答案,再更新原结点
bfs代码
struct edg{
int nxt,y;
}e[210000];//两倍边
int ltp=0,lk[110000];
void ist(int x,int y){
e[++ltp]=(edg){lk[x],y};
lk[x]=ltp;
}
int n;
int q[110000],hd=0;
int f[110000];//因为存的双向边,所以要记忆化
void bfs(int x)
{
q[hd=1]=1;
for(int k=1;k<=hd;k++)
{
for(int i=lk[q[k]];i;i=e[i].nxt)
{
if(!f[e[i].y])
{
q[++hd]=e[i].y;
f[e[i].y]=true;
}
}
}
for(int i=1;i<=hd;i++)
cout<<q[i]<<" ";
cout<<endl;
}
int main()
{
cin>>n;
int l,r;
for(int i=1;i<n;i++)
{
cin>>l>>r;
ist(l,r);
}
bfs(1);
return 0;
}
dfs代码
struct edg{
int nxt,y;
}e[210000];//两倍边
int ltp=0,lk[110000];
void ist(int x,int y){
e[++ltp]=(edg){lk[x],y};
lk[x]=ltp;
}
int n;
int q[110000],hd=0;
int f[110000];//因为存的双向边,所以要记忆化
void dfs(int x,int y)//y表示x的父结点
{
cout<<x<<' ';
for(int i=lk[x];i;i=e[i].nxt)
{
if(e[i].y!=y)//dfs只需避免遍历父亲即可,不用记忆化
dfs(e[i].y,x);
//当然BFS也可以通过记录父结点的方式防止死循环
}
}
int main()
{
cin>>n;
int l,r;
for(int i=1;i<n;i++)
{
cin>>l>>r;
ist(l,r);
}
bfs(1);
dfs(1,0);//1的父结点是0
return 0;
}
一种特殊的树
树除了有根树和无根树以外基本上没有什么分类
但有一种特殊类型的树出现次数很多,而且性质也很值得研究
那就是 二 叉 树
二叉树的定义很简单,每个节点最多有2个儿子的树就是二叉树
二叉树一般是有根树(无根树不好定义儿子的概念)

二叉树的形状
和普通树不同,二叉树的儿子大多时候回分为做儿子和有儿子
这时不同形状的二叉树不同
具体时间什么时候区分左右儿子要看上下文环境
如下两个二叉树是不同的,但看成普通树认为是相同的

二叉树的类型
二叉树作为特殊的树,自己也有两个特殊的类型
那就是满二叉树和完全二叉树
其实这两个概念也很简单
-
满二叉树
满二叉树指除了最后一层的结点外,所有的结点都有两个儿子的树

-
完全二叉树
完全二叉树指按从上到下,从左到右的顺序给所有结点标号,如果一个二叉树和另一个满二叉树在相同的位置有相同的标号,就说这个二叉树是完全二叉树
(从上到下从左到右依次填满,就是一个完全二叉树)
(温馨提示:不用背哦~)
二叉树的储存
二叉树可以和普通树一样存储,但我们一般不这样做
因为二叉树只用两个儿子,所以邻接链表也不用了,直接用数组来存储即可
数组可以用两个数组,也可以用\(n*2\)的数组,也可以用结构体数组,这个就看个人喜好了
二叉树的遍历
二叉树的遍历和普通树的遍历的不同之处在于遍历顺序的方式有三种
先序遍历、中序遍历和后序遍历,这些顺序都是针对DFS而言的,二叉树的BFS提到的很少
这里储存遍历的顺序也可以是其他动作的顺序,比如Dp里进行状态转移的顺序,不这个是后话了
- 先序遍历
先序遍历就是在遍历两个儿子之前存储当前结点

性质:
-
同一个自述中的结点都紧密相连,恰好组成一个区间
-
在子树对应的区间中,子树的根结点总是位于最左端
上图中的区间:【【1 【2 【5】 【4】】】【【3】 【6】】】
- 中序遍历
中序遍历是遍历完左儿子之后存储

性质:
-
同先序遍历,一个子树的所有结点恰好组成一个区间
-
子树根结点区间分成两部分,左边为左子树,右边为右子树
区间:【【【5】 2 【4】】【3 【6】】】
- 后序遍历
后序遍历指遍历完两个儿子后存储

性质:
-
同先序遍历和中序遍历
-
类似先序遍历,子树根结点总是在区间的最右端
区间:【【【5】 【4】 2】 【【6】 3】 1】
总结一下:
先序遍历:根、左、右
中序遍历:左、根、右
后序遍历:左、右、根
树形Dp
树问题中最容易考虑的可能就是树形Dp了
但是实际上树形Dp和普通Dp没有什么区别同样不会
一般来说,某个节点的状态转移自他的所有儿子
那么先对所有子树进行遍历然后在用儿子的答案更新当前节点的答案即可
看在我写的这么认真,你看的也这么认真的份上,给个赞再走吧QAQ

浙公网安备 33010602011771号