【笔记】树与图论

》》b站视频链接《《

》》b站视频链接《《

OP

下文V为点数,E为边数

图的储存方式

对于一种图的储存方式,我们需要做到:
1.可以遍历一个顶点连接的所有边;
2.可以判断某条边是否存在;
3.可以建图(添加边或删除边);

邻接矩阵

一般来存较稠密的图;用二维数组表示连通性;
若有向边 A → B A\rightarrow B AB 边权为 p , 即可表示为 S [ A ] [ B ] = p S[A][B]=p S[A][B]=p

空间复杂度 O ( V 2 ) O(V^2) O(V2)
时间复杂度:
遍历一个点的所有边 O ( V ) O(V) O(V)
判断一条边是否存在 O ( 1 ) O(1) O(1)
增减一条边 O ( 1 ) O(1) O(1)

邻接表

一般存较稀疏的图;对每一个顶点维护一个线性表,将边存到与这个边有关的顶点的线性表中;

空间复杂度 O ( E ) O(E) O(E)
时间复杂度(假设以某点为起始点的边有 m 条):
遍历该点的所有边 O ( m ) O(m) O(m)
判断以该点为起始点的一条边是否存在 O ( m ) O(m) O(m)
增加一条边 O ( 1 ) O(1) O(1)
删除指定一条以该点为起始点的边 O ( m ) O(m) O(m)

vector实现

个人在代码实现上,对于有权图,一般使用vector<pair<int,int> >rod[v];,若有向边 A → B A\rightarrow B AB 边权为 p , 即rod[A].push_back({B,p});

链式前向星

即用数组保存邻接表;

存储方式:
使用一个数组存下所有边,每个元素记录这条边的两个端点和下一个同始点的边的数组下标;
用另一个数组记录以某个点为起始点的第一条边的下标;

加边方式类似于链表的头插法;
删边就比较麻烦了;

遍历需要的时间比vector数组少;

代码实现:

typedef struct{
	int x,y,next;
}road;

road rod[V+1];
int head[E+1],cnt;
//添加边 a->b
void addroad(int a,int b)
{
	cnt++;
	rod[cnt]={a,b,head[a]};
	head[a]=cnt;
}
//遍历 以a为起始点的所有边
void traverseroad(int a)
{
	for(int i=head[a];i;i=rod[i].next)
	{
		operate();
	}
}

如果要存储边权,在结构体中再加一个变量即可;

图的遍历

图的遍历是指从图中的某一个顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次且仅访问一次;

主要有两种策略,即BFS与DFS;

拓扑排序

应用:
1.有向图找环(有拓扑排序则没有环);
2.解决依赖问题(有边 A → B A\rightarrow B AB 代表 B 依赖 A ,拓扑排序中 A 的位置一定在 B 的前面);

拓扑排序是图的所有顶点的一个排列(所有点出现且仅出现一次);

时间复杂度 O ( V ) O(V) O(V)

过程及代码实现

类似于BFS的结构:
1.将所有入度为0的点加入队列;
2.队首元素出队,将以其为起始点的所有边删除后,与其相邻的所有点中,将所有入度为0的点加入队列;
3.循环上述过程,直至队空;

加入队列的顺序即为拓扑排序后的顺序;

如果入队元素个数≠V,则说明此有向图中有环;

queue<int>que;
int cnt[V+1];//cnt[i]为点i的入度,处理过程略;

void topologicalsort()
{
	for(int i=1;i<=n;i++)
		if(!cnt[i])que.push(i);
	while(!que.empty())
	{
		int u=que.front();
		que.pop();
		for(auto v:rod[u])//遍历,具体方法依存图方式而不同
		{
			cnt[v]--;//操作时,不需要真正删边,只需要将对应点的入度减一即可
			if(!cnt[v])que.push(v);
		}
	}
}

最短路

从一个顶点到另一个顶点的最短路径;

这段文字同时也参考了这些文章:关于INF关于Bellman-Ford关于SPFA

Bellman-Ford算法

图中可以包含负边或者负环;

中心思想就是对于一条最短路,最多经过 n - 1 条边;
每轮我们遍历所有边对每个点到初始点的最短距离进行更新(松弛),进行 n - 1 轮之后就可以保证记录的所有点到初始点的距离最短;

如果在 n - 1 轮之后仍可以进行松弛操作,则说明图中含有负环;

时间复杂度 O ( V E ) O(VE) O(VE)

代码
const int INF=0x3f3f3f3f;
vector<pair<int,int> >rod[V+1];//存图,<点,边权>

void bellman_ford(int s)
{
	int dis[V+1];
	for(int i=1;i<=V;i++)dis[i]=INF;//初始化
	dis[s]=0;
	for(int i=1;i<=E-1;i++)
	{
		for(int j=1;j<=V;j++)
		{
			for(int k=0;k<rod[j].size();k++)//这两个循环以遍历所有道路
			{
				if(dis[j]+rod[j][k].second<dis[rod[j][k].first])
					dis[rod[j][k].first]=dis[j]+rod[j][k].second;//松弛
			}
		}
	}
}
SPFA优化

只有当原点到点 u 的距离被更新时,与点 u 相连点到原点的距离才有可能需要被更新;

一些资料表示这种优化的正确性没有被证明,同时一些经验也表示优化后的用时可能被卡至与Bellman-Ford用时相似甚至更长;

时间复杂度 O ( V E ) O(VE) O(VE)

代码
const int INF=0x3f3f3f3f;
vector<pair<int,int> >rod[V+1];//存图,<点,边权>

void bellman_ford(int s)
{
	int dis[V+1];
	bool inq[V+1];
	for(int i=1;i<=V;i++)dis[i]=INF,inq[i]=0;//初始化
	dis[s]=0;
	queue<int>que;
	que.push(s);//始点入队
	inq[s]=1;
	while(!que.empty())
	{
		int now=que.front();
		que.pop();
		inq[now]=0;//去标记
		for(int i=0;i<rod[now].size();i++)//遍历与此点相连的点
		{
			if(dis[now]+rod[now][i].second<dis[rod[now][i].first])//如果可松弛
			{
				dis[rod[now][i].first]=dis[now]+rod[now][i].second;//松弛
				if(!inq[rod[now][i].first])//避免重复入队
				{
					que.push(rod[now][i].first);
					inq[rod[now][i].first]=1;//打标记
				}
			}
		}
	}
}
Dijkstra单源最短路

边权全为正数;

策略:
每次访问与最近的已访问点距离该已访问点与原点距离之和(之和即与原点距离)最小的未访问点,并由确定此点与原点的距离;

由于没有负权边,每次被访问的点不会被其它路径更新为更小的距离,即原点到该点的最短路距离被一次性确定;

时间复杂度 O ( V l o g E ) O(VlogE) O(VlogE)

堆优化代码
vector<pair<int,int> >rod[V+1];//存图,<点,边权>

int dis[V+1];
bool vis[V+1];
void dijk(int s)
{
	typedef pair<int,int> pii;
	priority_queue<pii,vector<pii>,greater<pii> >que;
	memset(dis,0x3f,sizeof dis);
	memset(vis,0,sizeof vis);
	dis[s]=0;
	que.push({0,s});//先将s入队,不标记被访问,下面循环中再加入相邻边;
	while(!que.empty())
	{
		int now=que.top().second;
		que.pop();//堆顶元素在加入时已经被预更新距离了,所以.first不需要再使用
		if(vis(now))continue;
		vis[s]=1;
		for(int i=0;i<rod[now].size();i++)
		{
			if(dis[rod[now][i].first]>rod[now][i].second+dis[now])
			{//可被更新的点一定是未访问点,否则存在负权边
				dis[rod[now][i].first]=rod[now][i].second+dis[now];
				que.push({dis[rod[now][i].first],rod[now][i].first});
			}
		}
	}
}
Floyd多源最短路

求图中两两点之间的最短路;

可有负边;

使用了动态规划的思想:
d [ k ] [ i ] [ j ] d[k][i][j] d[k][i][j] 为除 i 与 j 外只经过前 k 个点,从 i 到 j 的最短路;
如果加入第 k 个点之后,如果 i , j 间最短路有更新,则变化后的路径一定是以 k 为中间节点,所以有状态转移方程 d [ k ] [ i ] [ j ] = m i n ( d [ k − 1 ] [ i ] [ j ] , d [ k − 1 ] [ k ] [ j ] + d [ k − 1 ] [ i ] [ k ] ) d[k][i][j]=min(d[k-1][i][j],d[k-1][k][j]+d[k-1][i][k]) d[k][i][j]=min(d[k1][i][j],d[k1][k][j]+d[k1][i][k])

其中第一维可被优化;

时间复杂度 O ( V 3 ) O(V^3) O(V3)

代码
int d[V+1][V+1];//存图,无边的位置为INF,主对角线为0

void floyd()
{
	for(int i=1;i<=V;i++)d[i][i]=0;
	for(int k=1;k<=V;k++)
		for(int i=1;i<=V;i++)
			for(int j=1;j<=V;j++)
				d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
}

最小生成树

Prim算法(加点法)

类似与Dijkstra,维护一个联通的点集,每次循环找到距离这个集合最近的点,将其加入集合,并把之间的边计入;

时间复杂度 O ( V l o g V + E ) O(VlogV+E) O(VlogV+E)

Kruskal算法(加边法)

按边权从小到大枚举每条边 ( x , y ) ,如果 x 与 y 不在一个连通块中,就将这两个点加入同一个连通块,并把边计入;

具体判通操作可以用并查集实现;

时间复杂度 O ( V + E l o g E ) O(V+ElogE) O(V+ElogE)

代码

在这里插入图片描述

using namespace std;
typedef long long ll;
int fa[200005], cnt[200005]; //fa存该城所在集合的根节点(城市),cnt存以该城为根的集合城市数
ll len[200005];//len存以该城为根的集合内部的连通代价
struct road
{
    int fr, to;
    ll lrd;
} rod[500005];
bool cmp(road a, road b)
{
    return a.lrd < b.lrd;
}
int find(int x)
{
    if (fa[x] != x)
        fa[x] = find(fa[x]);
    return fa[x];
}
int main()
{
    int n, q, i;
    while (~scanf("%d%d", &n, &q))
    {
        int g1, g2, glen;
        for (i = 1; i <= q; i++)
        {
            scanf("%d%d%d", &g1, &g2, &glen);
            rod[i].fr = g2, rod[i].to = g1, rod[i].lrd = glen; //存路
        }
        sort(rod + 1, rod + 1 + q, cmp);
        for (i = 1; i <= n; i++)
            fa[i] = i, cnt[i] = 1, len[i] = 0; //初始化并查集
        for (i = 1; i <= q; i++)
        {
            int frn = rod[i].fr, ton = rod[i].to;
            if (find(frn) != find(ton))
            {
                if (find(frn) > find(ton))//保证两集以较小根为新根
                    swap(frn, ton);
                cnt[find(frn)] += cnt[find(ton)];
                len[find(frn)] += len[find(ton)] + rod[i].lrd;
                fa[find(ton)] = find(frn);
            }
        }
        if (cnt[1] == n)
            printf("%lld\n", len[1]);
    }
    return 0;
}

树的直径、重心与中心

参考

树的直径

树的直径定义为树上距离最远两点间的距离;

下面两个算法时间复杂度大致相同;

DP法

定义 d1[u] 表示 u 到达子树中叶子节点的最长链,d2[u] 表示 u 到达子树中叶子节点的次长链,并且两条链不能有交集;

那么答案即为 max i = 1 i < = v ( d 1 [ i ] + d 2 [ i ] ) \text{max}_{i=1}^{i<=v}(d1[i]+d2[i]) maxi=1i<=v(d1[i]+d2[i])

在具体操作中,只要保证 d1[u] 与 d2[u] 不被 u 的同一个子节点更新,即可保证 d1 与 d2 无公共部分;

vector<pair<int, ll> > rod[100005];//存图
ll d1[100005], d2[100005];
void dfs(int u, int f)
{
    for (int i = 0; i < rod[u].size(); i++)
    {
        if (rod[u][i].first == f)
            continue;
        dfs(rod[u][i].first, u);
        if (d1[rod[u][i].first] + rod[u][i].second > d1[u])
            d2[u] = d1[u], d1[u] = d1[rod[u][i].first] + rod[u][i].second;
        else if(d1[rod[u][i].first] + rod[u][i].second > d2[u])
            d2[u]=d1[rod[u][i].first] + rod[u][i].second;
    }
}
ll tree_r(int n)//传入总点数
{
	for(int i=1;i<=n;i++)d1[i]=d2[i]=0;
    ll ans=0;
    dfs(1,1);
    for(int i=1;i<=n;i++)
    {
        ans=max(ans,d1[i]+d2[i]);
    }
    return ans;
}

实际上以哪个节点作为根节点进行dfs并不重要,树的直径不随根的改变而改变;

双DFS

首先以任意点x为根进行一次dfs,找出距离x距离最大的点pos;
再以pos为根进行一次dfs,距离pos最远的距离即为树的直径;

证明:

假定pos是直径的一端,显然距离pos最远的距离即为树的直径;

即证pos一定在直径的一端:
若pos不是叶节点,则以pos为根的子树中一定存在一叶节点与x距离大于pos与x距离

即证pos在直径上:
假设pos不在直径上,直径上距离x最远点为k1,另一端点为k2

若x在直径上:
即代表x与pos的距离大于x与k1的距离,显然pos->x->k2路径长度大于k1->x->k2路径长度,与直径定义相悖

若x不在直径上:
假设直径上距离x最近的点为x1,即代表x->pos路径长大于x->x1->k1路径长,意味着k2->x1->x->pos路径长大于k2->x1->x->x1->k1路径长,与直径定义相悖

即pos一定在直径上;

即pos一定为直径的一端;

vector<pair<int, ll> > rod[N];
ll d[N];
int pos;
void dfs(int u, int f,int lth)
{
    d[u]=d[f]+lth;
    if(d[u]>d[pos])pos=u;
    for (int i = 0; i < rod[u].size(); i++)
    {
        if (rod[u][i].first == f)
            continue;
        dfs(rod[u][i].first, u,rod[u][i].second);
    }
}
ll tree_r(int n)
{
	for(int i=1;i<=n;i++)d[i]=0;
    pos=0;
    dfs(1,1,0);
    for(int i=1;i<=n;i++)d[i]=0;
    dfs(pos,pos,0);
    return d[pos];
}
树的重心

树的重心为该点下最大子树最小的点;

以树的重心为整棵树的根时,它的最大子树最小(也就是删除该点后最大联通块最小)

以任意一点为根进行dfs,定义 sz[i] 表示以 i 为根的子树的总大小,mson[i] 表示以 i 的最大子树的大小;

则有 m s o n [ i ] = m a x ( V − s z [ i ] , max j 为 i 的 子 节 点 ( s z [ j ] ) ) mson[i]=max(V-sz[i],\text{max}_{j为i的子节点}(sz[j])) mson[i]=max(Vsz[i],maxji(sz[j]))

重心即为 m s o n [ i ] mson[i] mson[i] 最小的节点;

vector<int> rod[N];
ll sz[N], mson[N];
int ans=0;
void dfs(int u, int f, int n)
{
    sz[u] = 1, mson[u] = 0;
    for (int i = 0; i < rod[u].size(); i++)
    {
        if (rod[u][i] == f)
            continue;
        dfs(rod[u][i], u, n);
        sz[u] += sz[rod[u][i]];
        mson[u] = max(mson[u], sz[rod[u][i]]);
    }
    mson[u] = max(mson[u], n - sz[u]);
    if(!ans)ans=u;
    else if(mson[u]<mson[ans])ans=u;
}//ans即为重心下标
树的中心

树的中心定义为到叶子节点最长距离最小的节点;

在树的直径中,我们已经理解了如何维护某点子树中的最长链长度和次长链长度,我们只需要将链的范围从子树扩展到全图即可;

定义 d1[u] 表示 u 到图中叶子节点的最长链,d2[u] 表示 u 到达图叶子节点的次长链,并且两条链不能有交集;

在树的直径的dfs中,我们已经维护了子树中的最长链与次长链,扩展到全图后,只需要维护从父节点方向的最长链即可;

如果父节点的最长链来自本节点,则本节点的链长可以更新自父节点次长链长到父节点边长的和;
若不来自此节点,则本节点的链长可以更新自父节点最长链长到父节点边长的和;

最后找到到叶子节点最长距离最小的节点即可;

vector<pair<int, ll>> rod[N]; //存图
ll d1[N], d2[N], d1f[N];
void dfs1(int u, int f)
{
    for (int i = 0; i < rod[u].size(); i++)
    {
        if (rod[u][i].first == f)
            continue;
        dfs1(rod[u][i].first, u);
        if (d1[rod[u][i].first] + rod[u][i].second > d1[u])
            d2[u] = d1[u], d1[u] = d1[rod[u][i].first] + rod[u][i].second, d1f[u] = rod[u][i].first;
        else if (d1[rod[u][i].first] + rod[u][i].second > d2[u])
            d2[u] = d1[rod[u][i].first] + rod[u][i].second;
    }
}
void dfs2(int u, int f)
{
    for (int i = 0; i < rod[u].size(); i++)
    {
        if (rod[u][i].first == f)
            continue;
        if (d1f[u] == rod[u][i].first)
        {
            if (d2[u] + rod[u][i].second > d1[rod[u][i].first])
            {
                d1f[rod[u][i].first] = u, d1[rod[u][i].first] = d2[u] + rod[u][i].second;
            }
            else if (d2[u] + rod[u][i].second > d2[rod[u][i].first])
            {
                d2[rod[u][i].first] = d2[u] + rod[u][i].second;
            }
        }
        else
        {
            if (d1[u] + rod[u][i].second > d1[rod[u][i].first])
            {
                d1f[rod[u][i].first] = u, d1[rod[u][i].first] = d1[u] + rod[u][i].second;
            }
            else if (d1[u] + rod[u][i].second > d2[rod[u][i].first])
            {
                d2[rod[u][i].first] = d1[u] + rod[u][i].second;
            }
        }
        dfs2(rod[u][i].first, u);
    }
}
int tree_m(int n) //传入总点数
{
    for (int i = 1; i <= n; i++)
        d1[i] = d2[i] = d1f[i] = d2f[i] = up[i] = 0;
    int ans = 1;
    dfs1(1, 1);
    dfs2(1, 1);
    for (int i = 1; i <= n; i++)
    {
        if (d1[i] < d1[ans])
            ans = i;
    }
    return ans;
}

LCA

参考
LCA 全称 Least Common Ancester 即最近公共祖先;

首先考虑两个深度一样的点,对于两个深度相同的点 x,y , 他们到LCA的深度差是相同的,那么我们可以采取x,y同时向上移动,直到移动到相同的节点。

如果线性地向上跳,时间复杂度不是很让人满意,由于祖先存在二段性,我们可以通过这个性质进行倍增,找到最远的不同祖先,再找到那个祖先的父节点;

如果两个结点深度不同,我们可以将较深结点向上回溯至与另一节点深度相同,再进行上移;

对于倍增的具体过程如下:

定义 f a [ u ] [ j ] fa[u][j] fa[u][j] 为 u 结点的 2 j 2^j 2j 级祖先,有递推式 f a [ i ] [ j ] = f a [ f a [ i ] [ j − 1 ]   ] [ j − 1 ] fa[i][j]=fa[fa[i][j−1]\ ][j−1] fa[i][j]=fa[fa[i][j1] ][j1]

对于两个同级结点,其 LCA 及更高层的祖先均相同,我们可以按照这个二段性进行倍增;

j 从大到小遍历:

  1. f a [ u ] [ j ] = f a [ v ] [ j ] fa[u][j]=fa[v][j] fa[u][j]=fa[v][j] 则说明 2 j 2^j 2j 级结点不低于LCA,此时不需要将 u,v移至此点;
  2. f a [ u ] [ j ] = f a [ v ] [ j ] fa[u][j]=fa[v][j] fa[u][j]=fa[v][j] 则说明 2 j 2^j 2j 级结点低于LCA,可以将 u,v移至此点;

依照此策略进行迭代,会使 u,v移至LCA的两个子节点,之后返回父节点即可;

整个过程中类似于二进制拆分~

int fa[N + 1][M + 1], dep[N + 1];
vector<int> rod[N + 1];
int LCA(int x, int y, int s)
{
    if (dep[x] < dep[y])
        swap(x, y);
    while (dep[x] > dep[y])
        x = fa[x][(int)log2(dep[x] - dep[y])];//倍增找平
    if (x == y)
        return y;
    for (int i = M; i >= 0; i--)//寻找深度最小的不同结点
    {
        if (fa[x][i] != fa[y][i])
        {
            x = fa[x][i], y = fa[y][i];
        }
    }
    return fa[x][0];//返回其父
}
void dfs(int u, int f)
{
    fa[u][0] = f;
    dep[u] = dep[f] + 1;
    for (auto v : rod[u])
    {
        if (v != f)
            dfs(v, u);
    }
}
void lca_init(int s, int n)
{
    dfs(s, 0);//处理出fa[u][0]
    for (int j = 1; j <= 20; j++)
        for (int i = 1; i <= n; i++)
            fa[i][j] = fa[fa[i][j - 1]][j - 1];//更新其他距离
}

DFS序

参考

DFS序可以将一个树状数据拍到一个线性数据上,方便了进一步的处理;

DFS序即指每个节点在dfs深度优先遍历中的进出栈的时间序列;

对于每个节点,存储两个参数,即 i n [ i ] , o u t [ i ] in[i],out[i] in[i],out[i] i n [ i ] in[i] in[i] 表示 i 号节点进入dfs栈的时间编号, o u t [ i ] out[i] out[i]即为 i 号节点离开dfs栈的时间编号;

以下树为例:
在这里插入图片描述
从1开始的dfs序即为: 1 , 2 , 4 , 4 , 5 , 9 , 9 , 5 , 2 , 3 , 6 , 6 , 7 , 7 , 8 , 8 , 3 , 1 1,2,4,4,5,9,9,5,2,3,6,6,7,7,8,8,3,1 1,2,4,4,5,9,9,5,2,3,6,6,7,7,8,8,3,1
在dfs序中, i n [ i ] in[i] in[i]为 i 号节点第一次出现在dfs序中的时间编号, o u t [ i ] out[i] out[i]即为第二次出现的时间编号;

代码
//vector<int>tre[N];存图
pair<int,int>_ti[N];//_ti[i].first表示i节点的in,.second表示i节点的out
int now=0;
void get_time(int u, int fa = -1) {
    _ti[u].first = now++;
    for (auto t : tre[u]) {
        if (t == fa) continue;
        get_time(t, u);
    }
    _ti[u].second = now++;
}
性质
  1. 每个时间刻都是相异的,不存在同一时间刻出现两次的情况;
  2. 任意不同两点 u , v 不存在 i n [ u ] < i n [ v ] < o u t [ u ] < o u t [ v ] in[u]\lt in[v]\lt out[u]\lt out[v] in[u]<in[v]<out[u]<out[v] 的情况,即两点所覆盖的区间只有相互包含和相离两种情况,不会相互交错;
    可以用此性质在 O ( n ) O(n) O(n) 复杂度上预处理后, O ( 1 ) O(1) O(1) 复杂度判断树上给定两点是否存在祖先关系,若存在祖先关系,则子孙点所覆盖的区间是祖先点区间的真子集;
  3. 在DFS序中,任意子树的DFS序都是连续的;
  4. 任意点对(a,b)之间的路径,可分为两种情况,首先是令lca是a、b的最近公共祖先:
    1.若lca是a,b之一,则区间 [ i n [ a ] , i n [ b ] ] [in[a],in[b]] [in[a],in[b]] 或者区间 [ o u t [ a ] , o u t [ b ] ] [out[a],out[b]] [out[a],out[b]] 就是其路径。
    2.若lca不是a,b之一,则路径区间 [ i n [ a ] , o u t [ b ] ] [in[a],out[b]] [in[a],out[b]] 或者 [ i n [ b ] , o u t [ a ] ] [in[b],out[a]] [in[b],out[a]] ,再在lca的两个子节点之间加上lca本身。
    区间中可能会有重复出现的点,说明在此点处有绕路,用栈处理掉即可;

强连通分量

强连通分量定义为有向图的一个子图,从该子图的任意一点出发可以到达子图上的任意另一点;

我们先对每一个未vis的点进行dfs,得到一个dfs树。对于这个dfs树中有以下规律:

如果某点存在到其祖先节点的路径(后称为返祖边),则该祖先节点到该点之间构成一个环,环上所有点均满足强连通分量;
如果两个环间存在公共点,则两个环上所有点均在同一个强连通分量中;

依照以上规律,我们可以维护以每个点为根的子树中的节点,通过一次返祖边所连接的最浅(靠近此dfs树的根)祖先,并以这个祖先作为这个强连通分量的代表元,来记录这些点所处的强连通分量;

以上维护过程可以在dfs回溯过程中实现,直到回溯至某点自身即为代表元,表示其下面留存的所有点均为此连通分量的元素;

具体留存哪些点可以使用栈来维护,每次dfs到新元素时将其入栈,每次回溯至代表元时将栈顶到代表元间的所有元素(含代表元)全部出栈,同一批次出栈的元素均在同一个强连通分量中;

对于的维护,可以通过dfs序实现;

vector<int> rod[N]; //存路
stack<int> s;       //s为留存点栈
int dfn[N], dc = 1; //dfs存储dfs入序,dc用于分配dfs序
int low[N];         //low存储节点下子树经过一次返祖边所连接的最小dfs序
int scc[N], sc = 1; //scc存储节点所在的强连通分量编号,sc用于分配强连通分量编号

void dfs(int u)
{
    dfn[u] = low[u] = dc++;//标记dfs序,初始化low
    s.push(u);
    for (int i = 0; i < rod[u].size(); i++)
    {
        int to = rod[u][i];
        if (!dfn[to])
            dfs(to);
        if (!scc[to])//若未被标记强连通分量编号,即to为自由点
            low[u] = min(low[u], low[to]);
    }
    if (low[u] == dfn[u])
    {
        while (!s.empty())
        {
            int now = s.top();
            s.pop();
            scc[now] = sc;//点now属于强连通分量sc
            if (now == u)
                break;
        }
        sc++;
    }
}
//在主函数中,遍历每棵dfs树
for(int i=1;i<=n;i++)
{
    if(!dfn[i])dfs(i);
}
//后续想法:vis用于确定是否被访问的作用可以被dfn代替
缩点建图

求出有向图上的强连通分量后,可以将每个强连通分量对应的点集看作一个点,重新建图,得到一个DAG(有向无环图);

具体实现上:

遍历每一条边,若两端在同一强连通分量中,则忽略;
若在不同的强连通分量中,则在对应的两个强连通分量间建一条边;

vector<int> rod2[N];//存新路
//主函数中
for(int i=1;i<=n;i++)
{
    for(int j=0;j<rod[i].size();j++)
    {
        if(scc[i]!=scc[rod[i][j]])
        {
            rod2[scc[i]].push_back(scc[rod[i][j]]);
        }
    }
}

缩点后的图有以下性质:

  1. 以所有入度为0的点为根进行dfs即可不重复地遍历每张图;
  2. 缩点后的图不存在点集元素数大于1的强连通分量,即每一个点都独自作为强连通分量。若要通过加边使此图成为一个强连通分量,则还需要加 m a x ( 零 入 度 点 数 , 零 出 度 点 数 ) max(零入度点数,零出度点数) max(,) ,具体策略为优先从零出度点向零入度点连边(此后这两个点均不是零入度点 / 零出度点),剩余未连的点若为零入度点,则从另外任意一点向其连边,若为零出度点同理;注意对缩点后的图只有一个点的情况的特判;

割点

在无向图中,如果一个点删除后连通块的个数增加,则称该点为割点;

割点的求法:

对于每个连通分支单独考虑:

对连通块进行dfs,每个连通块对应一个dfs树;

有如下定理:
一个点为割点当且仅当:

  1. 其为dfs树的根,且有两个及以上的儿子节点;
  2. 其不为dfs树的根,且存在一个以其儿子节点为根的子树满足 子树中所有点仅通过一次返祖边能到达点的最小dfs序大于等于该点的dfs序;

在实际操作时,可以不屏蔽树枝边,对结果没有影响;

vector<int> rod[N]; //存路
int dfn[N], dc = 1; //dfs存储dfs入序,dc用于分配dfs序
int low[N];         //low存储节点下子树经过一次边所连接的最小dfs序
int cut[N];         //cut存储该点删去后,该点所在的连通块会变成几块,cut[i]>1的为割点
void dfs(int u, int r)
{
    dfn[u]=low[u]=dc++;
    cut[u]=(u==r)?0:1;
    for(int j=0;j<rod[u].size();j++)
    {
        int to=rod[u][j];
        if(!dfn[to])
        {
            dfs(to,r);
            low[u]=min(low[u],low[to]);
            if(low[to]>=dfn[u])cut[u]++;
        }
        else low[u]=min(low[u],dfn[to]);
    }
}
//主函数中
for (int i = 1; i <= n; i++)
{
    if (!dfn[i])
        dfs(i, i);
}
点-双连通分量

一个无向图中尽可能大的,内部不包含割点的子图称为该无向图的一个点-双连通分量,简称点双;

也就是说,如果任意两点至少存在两条“点不重复”的路径,就说这个图是点-双连通分量,等价于内部无割点;

可以认为,同一个连通块内的不用点双由一个个割点“切”开的;

在求割点的过程中,每当出现一个割点,即出现if (low[to] >= dfn[u])(根节点亦然),即将dfs栈中to节点及以上的节点出栈,并与u节点共同组成一个点双;

注意对只有一个点的连通块进行特判,上面的策略无法处理这种情况;

stack<int> s;       //dfs栈
vector<int> rod[N]; //存路
int dfn[N], dc = 1; //dfs存储dfs入序,dc用于分配dfs序
int low[N];         //low存储节点下子树通过返祖边所连接的最小dfs序
vector<int> scc[N];
int sc = 1; //scc存储每个双连通分量内的元素,sc分配双连通分量编号
void dfs(int u, int r)
{
    s.push(u);
    low[u] = dfn[u] = dc++;
    if (u == r && rod[u].empty())
    {
        int now = s.top();
        s.pop();
        scc[sc].push_back(now);
        sc++;
    }
    for (int j = 0; j < rod[u].size(); j++)
    {
        int to = rod[u][j];
        if (!dfn[to])
        {
            dfs(to, r);
            low[u] = min(low[u], low[to]);
            if (low[to] >= dfn[u])
            {
                int now;
                do
                {
                    now = s.top();
                    s.pop();
                    scc[sc].push_back(now);
                } while (now != to);
                scc[sc].push_back(u);
                sc++;
            }
        }
        else
        {
            low[u] = min(low[u], dfn[to]);
        }
    }
}
//主函数中
for (int i = 1; i <= n; i++)
{
    if (!dfn[i])
        dfs(i, i);
}

割边

在无向图中,如果一个边删除后连通块的个数增加,则称该边为割边;

求法和割点类似,先对每个连通块分别处理,每个连通块产生一个dfs树;显然,只有树枝边可能作为割边;

有以下定理:

若一条树枝边为割边,当且仅当子节点经过非树枝边(非树枝边包含树枝边的重边)所能到达节点的dfs序大于父节点;

vector<int> rod[N]; //存路
int dfn[N], dc = 1; //dfs存储dfs入序,dc用于分配dfs序
int low[N];         //low存储节点下子树通过非树枝边所连接的最小dfs序
void dfs(int u, int fa)
{
    low[u] = dfn[u] = dc++;
    for (int j = 0; j < rod[u].size(); j++)
    {
        int to = rod[u][j];
        if(to==fa)
        {
            fa=0;
            continue;
        }
        if (!dfn[to])
        {
            dfs(to, u);
            low[u] = min(low[u], low[to]);
            if (low[to] > dfn[u])
            {
                //此时j边即为割边,进一步处理
            }
        }
        else
        {
            low[u] = min(low[u], dfn[to]);
        }
    }
}
//主函数中
for (int i = 1; i <= n; i++)
{
    if (!dfn[i])
        dfs(i, 0);
}
边-双连通分量

一个无向图中尽可能大的,内部不包含割边的子图称为该无向图的一个边-双连通分量,简称边双;

同样地,可以认为,同一个连通块内的不用边双由一个个割边“切”开的;

在求割边的过程中边dfs边压栈,直到遇到割边,就将栈内边下元素均出栈,同一批出栈的点构成一个边双;

stack<int> s;
vector<int> rod[N]; //存路
int dfn[N], dc = 1; //dfs存储dfs入序,dc用于分配dfs序
int low[N];         //low存储节点下子树通过非树枝边所连接的最小dfs序
vector<int> scc[N];
int sc = 1;
void dfs(int u, int fa)
{
    s.push(u);
    low[u] = dfn[u] = dc++;
    for (int j = 0; j < rod[u].size(); j++)
    {
        int to = rod[u][j];
        if (to == fa)
        {
            fa = 0;
            continue;
        }
        if (!dfn[to])
        {
            dfs(to, u);
            low[u] = min(low[u], low[to]);
        }
        else
        {
            low[u] = min(low[u], dfn[to]);
        }
    }
    if (dfn[u] == low[u])
    {
        int now;
        do
        {
            now = s.top();
            s.pop();
            scc[sc].push_back(now);
        } while (now != u);
        sc++;
    }
}
//主函数中
for (int i = 1; i <= n; i++)
{
    if (!dfn[i])
        dfs(i, 0);
}

Prufer数列

参考1&参考2

Prufer数列是无根树的一种数列,通过一个Prufer序列可以唯一表示一棵节点带标号的无根树,点数为n的树转化来的Prufer数列长度为 n - 2 ;

树生成数列

我们需要重复进行以下操作,直至树中只剩下两个点:

  1. 找到一个度数为1,且编号最小的点。(其中编号最小保证了后面将会提到的prufer序列的唯一对应性,同时也方便从prufer序列转化回无根树)
  2. 把这个点的父节点加入序列,然后把这个点从树中删除。

然后我们就得到了一个长度为 n − 2 的序列;

如下图所示的无根树
在这里插入图片描述

其Prufer序列则为 { 2 , 3 , 1 , 2 , 1 } \{2,3,1,2,1\} {2,3,1,2,1}

数列生成树

我们需要重复进行以下操作,直至点集中只剩下两个点:(初始化所有点都在点集中)

  1. 取出prufer序列最前面的元素x。
  2. 取出在点集中的、且当前不在prufer序列中的最小元素y。(这恰好对应了前面提到过的选取编号最小的节点)
  3. 在x,y之间连接一条边。
    (注意前面的取出相当于删除)

最后,我们在点集中剩下的两个点中连一条边。

显然这有n-1条边,且不会形成环,因此它是一棵树,且就是原树;

性质
  1. prufer序列与无根树一一对应;
  2. 度数为 d i d_i di 的节点会在prufer序列中出现 d i − 1 d_i-1 di1 次;
    当某个节点度数为1时,会直接被删掉,否则每少掉一个相邻的节点,它就会在序列中出现1次;
  3. 一个n个节点的完全图的生成树个数为 n n − 2 n^{n-2} nn2
    对于一个n个点的无根树,它的prufer序列长为n-2,而每个位置有n种可能性,因此可能的prufer序列有 n n − 2 n^{n-2} nn2 种;
  4. 对于给定度数为 d 1 ∼ n d_{1\sim n} d1n的一棵无根树共有

∏ i = 1 n C n − 2 − ∑ j = 1 i − 1 ( d j − 1 ) d i − 1 = ( n − 2 ) ! ∏ i = 1 n ( d i − 1 ) ! \prod_{i=1}^nC_{n-2-\sum_{j=1}^{i-1}(d_j-1)}^{d_i-1}=\frac {(n-2)!}{\prod_{i=1}^n(d_i-1)!} i=1nCn2j=1i1(dj1)di1=i=1n(di1)!(n2)!

种情况;
该问题可以转化成有 2 n − 2 2n − 2 2n2 个位置,要放 n n n 个数,数 i 要(不要求连续地)占 d [ i ] − 1 d[i] − 1 d[i]1 个位置;

ED

\

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值