Tarjan 重温

upd.2025.7.12 小修部分逻辑错误/详细补充了部分文字解释。

upd.2025.7.13 大修点双/边双/有向图强连通的求法。

upd.2025.8.11 小修部分事实性错误,补充了部分没解释到位的地方。大修有向图强联通分量的求解

 

定义


割点:在无向图中,删去后使得连通分量数增加的点称为 割点。

割边:在无向图中,删去后使得连通分量数增加的边称为 割边,也称 桥。

点双连通图:不存在割点的无向连通图称为 点双连通图。根据割点的定义,孤立点和孤立边均为点双连通图。

边双连通图:不存在割边的无向连通图称为 边双连通图。根据割边的定义,孤立点是边双连通图,但孤立边不是。

点双连通分量:一张图的极大点双连通子图称为 点双连通分量(V-BCC),简称 点双。

边双连通分量:一张图的极大边双连通子图称为 边双连通分量(E-BCC),简称 边双。

 

强连通:对于有向图的两点 u,v,若它们相互可达,则称 u,v 强连通,这种性质称为 强连通性。

显然,强连通是等价关系,强连通性具有传递性。

强连通图:满足任意两点强连通的有向图称为 强连通图。它等价于图上任意点可达其它所有点。

强连通分量:有向图的极大强连通子图称为 强连通分量(Strongly Connected Component,SCC)。

DFS 树


无向图


 

有向图


 

 

割点


也就是考虑下某个点是割点的充要条件。

推导


因为 dfs 树有很好的性质,所以可以先按照原图进行 dfs 遍历,得到一颗 dfs 树。

记 x 的子树为 x 在 DFS 树上的子树,包含 x 本身,记作 $T(x)$。记 $T'(x) = V\backslash T(x)$,即整张图除了 $T(x)$ 以外的部分。

 
考虑断去 x,整颗树会分成 $T(x)$ 和 $T'(x)$,此时 x 为根会使 $T'(x)$ 为空,所以需要分讨 x 是否为根。
1.x 不为根

x 会有若干个子树 y,但是根据“子树独立性”,y 之间没有边相连。所以我们考虑单独的一个 y 即可。

即用 x 的子树判断 x 是否为割点。(下面的讨论围绕这句话展开,看懂了才不会晕)

断掉 x 点后,T(y) 内每个点根据树边连通,T'(x) 内每个点也根据树边连通

如果 T'(x) 内存在 z,T(y) 内存在 y',使得存在(y',z)这条边,那么 x 就不是割点。

可以肯定(y',z)这条边一定是非树边。考虑反证,如果是树边,那么 z=x,矛盾。

于是这启发我们定义:设 $f_x$ 表示与 x 通过 非树边 相连的所有点的时间戳的最小值。

则判 x 为割点的条件可写为 在 x 的子树内(此处指 T(y)),存在 $u$,对于任意一个 $u$ 都满足 $f_u \ge d_x$。($d_x$ 为 $x$ 的 dfs 序,即 dfn)

我们进一步归纳,设 $g_x$ 表示 $x$ 的子树内(此处指 T(x))所有点 $u\in T(x)$ 的 $f_u$ 的最小值(low 的真正含义)

根据树形 DP,有(即通过树边/非树边更新):

$g_x = \min\left(\min_{y'\in \mathrm{son}(x)} g_{y'}, \min_{(x, y) \in E\land (x, y)\notin T} d_y\right)$

也就是相当于通过儿子更新 $g_x$,或者考虑直接用 $x$ 本身更新 $g_x$(这里 $\land (x, y)\notin T$ 是指 (x, y) 是非树边且不是 $x$ 连向儿子的边)。

所以在遍历到树边或非树边对 $g$ 更新即可。判断 x 为割点的条件就变成了:$g_y \ge d_x$

 

2.x 为根

由于没了 T'(x),且根据“子树独立性”,只需要看 x 是否有 >=2 棵子树即可。

这样才能保证是关于 T(x) 与 T'(x) 之间的连通性分讨。

 

结合上面树形 DP 的分析,其实 low 相当于:low[u] 应该是 u 节点通过若干条树边(但不能是父亲方向的树边)和一条反祖边能到达的最小 dfn。

代码


一些实现的细节。

1.对于上面那个树形 DP 转移的后半部分,忽略 (x,y)必须是非树边也不会错。

这样可能会多出来一些非法的边,从而对判断 $x$ 是割点有影响。因为是通过 $x$ 的儿子 $y$ 判断 $x$ 是否为割点,我们分析 $g_y$ 如何变化即可。

多出来的非法边:

1)一些连向儿子的树边(注意到上面的 dp 式子应该满足(x,y)!=T 才行,即非儿子的非树边),但是用儿子更新肯定没问题

2)一条连向父亲的树边。用父亲更新,即用 $d_x$ 更新 $g_y$,但是由于判断条件是 $g_y \ge d_x$,取了个等号,于是没影响。

但是割边在此处不能忽略(2),因为判断割边的条件是 $g_y > d_x$,又因为 g 每次更新都是取 min,所以取等后该式就永远不可能成立了,即会认为每条边都不是割边。

所以要把 回去的路 给判掉,但不能只判 fa,这样会将树边的重边也判为树边,实际上应该是非树边,所以只有正常统计非树边才能更新 low,并让判割边的条件失效。(即 low[v]>dfn[u])

解决方法是记录边的编号,并且用成对的编号(2a,2a+1),这样就能将当前边异或 1 转换成反边编号,判掉 回去的路。

 

2.$g_x$ 初始成 $d_x$ 显然没问题,对于割点割边都不影响判断。

 

P3388 【模板】割点(割顶) - 洛谷

#include <bits/stdc++.h>
using namespace std;
const int N=2e4+5;

int n, m, u1, v1, rt=0, dfn[N], low[N], tim=0, ans=0, cut[N];
vector<int> a[N];
void tarjan(int u)
{
	int son=0;
	dfn[u]=low[u]=++tim;
	
	for (int i=0; i<a[u].size(); i++)
	{
		int v=a[u][i];
		if (!dfn[v])
		{
			tarjan(v);
			low[u]=min(low[u], low[v]);
			
			if (u!=rt && low[v]>=dfn[u]) cut[u]=1;
			son++;
		}
		else low[u]=min(low[u], dfn[v]);
	}
	
	if (u==rt && son>=2) cut[rt]=1; 
}
int main() 
{
    scanf("%d%d", &n, &m);
    for (int i=1; i<=m; i++)
    {
    	scanf("%d%d", &u1, &v1);
    	a[u1].push_back(v1);
    	a[v1].push_back(u1);
	}
	for (int i=1; i<=n; i++) if (!dfn[i]) rt=i, tarjan(rt);
	
	for (int i=1; i<=n; i++) ans+=cut[i];
	printf("%d\n", ans);
	for (int i=1; i<=n; i++) if (cut[i]) printf("%d ", i);
	
    return 0;
}

  

割边


同理,也就是考虑下某条边是割边的充要条件。

推导


和割点类似,割掉一条边 (u,v),其中 u 是 v 的父亲。会分成 T(v) 与 T'(v) 两部分。

只需要 T(v) 内的每个点不能到达 T'(v) 内的每个点,也就是 $g_v > d_u$。

注意此处是取大于,因为如果 T(v) 内的点能通过某条非树边到达 u,那(u,v)便不再是割边。

 

代码


具体见割点,有说过。

P1656 炸铁路 - 洛谷

#include <bits/stdc++.h>
using namespace std;
const int N=2e4+5;

struct node
{
	int v, w;
};
int n, m, u1, v1, rt=0, dfn[N], low[N], tim=0;
vector<node> a[N], ans;
bool cmp(node a, node b)
{
	if (a.v!=b.v) return a.v<b.v;
	return a.w<b.w;
}
void tarjan(int u, int from)
{
	dfn[u]=low[u]=++tim;
	
	for (int i=0; i<a[u].size(); i++)
	{
		int v=a[u][i].v, w=a[u][i].w;
		if (!dfn[v])
		{
			tarjan(v, w);
			low[u]=min(low[u], low[v]);
			
			if (low[v]>dfn[u]) ans.push_back({min(u, v), max(u, v)});
		}
		else if ((w^1)!=from) low[u]=min(low[u], dfn[v]);
	}
}
int main() 
{
    scanf("%d%d", &n, &m);
    for (int i=1; i<=m; i++)
    {
    	scanf("%d%d", &u1, &v1);
    	a[u1].push_back({v1, i<<1});
    	a[v1].push_back({u1, i<<1|1});
	}
	for (int i=1; i<=n; i++) if (!dfn[i]) tarjan(i, 0);
	
	sort(ans.begin(), ans.end(), cmp);
	for (int i=0; i<ans.size(); i++) printf("%d %d\n", ans[i].v, ans[i].w);
    return 0;
}

  

 

 

有向图强联通分量


推导


考虑先按 dfs 遍历生成出 dfs 树。

在 dfs 过程中,如果遍历到 u,结果有此时一条非树边,是不是会形成环?环内每个点是不是强联通?肯定的。此时的环就是强联通图了。

但是为了保证是找出强联通分量,我们用 low 记录 u 和它子树的所有节点 经过非树边能到达的最远点,即 dfn 最小值,就容易找出强联通分量了。

在处理 low 值时,为了方便知道强联通分量包括啥,我们用栈记录途经的点即可。

这里有向图的 dfs 生成树有横叉边,子树独立性挂掉了,原因就是多了横叉边,我们 ban 掉就好。

 

ban 横叉边肯定是在访问非树边时考虑的,然后处理 low 等等的信息肯定是先处理了树边再处理的非树边,所以出现一条非树边 (u, v) 时,v 一定已经被访问过了。

非树边可以分横叉边,返祖边,前向边。然后就可以分情况讨论了:

1.如果 u 是通过前向边到了点 v,low[u] 被不被更新都无所谓,没有影响,不用管前向边。

2.如果 u 是通过返祖边到了点 v,low[u] 被正常更新,没有影响,不用管。

3.如果 u 是通过横叉边到了点 v。

1)如果 v 还不属于某个 SCC(此时 v 在栈内,因为 v 肯定被访问过了,且 v 不属于某个 SCC),那么此时 u 可能与 v 以及一些其余点构成 SCC,所以要用 v 的信息更新 u。

此时的边 (u, v) 可以理解为返祖边,是有用的,不能 ban 掉。如此图(当前 u=4, v=6,如果用 v 的信息更新 u, u 会被仅有当前一个点 {4} 的 SCC,事实上应该构成一个包括 {2, 3, 4, 6} 的 SCC):

image

2)如果 v 属于某个 SCC(同理,此时 v 不在栈内),那么此时 u 不可能与 v 以及一些其余点构成 SCC,所以不能用 v 的信息更新 u。

也就是判下一个遍历的点 v 是否已经被加入到某个 SCC 内 或者 是否出栈 来判断是否为需要 ban 掉的横叉边。

 

 

再由 dfn 与 low 的性质:先把经过的点入栈,那么栈底到栈顶的点 dfn 严格递增,low 严格非降。

 

推导:对于一个连通分量图,我们很容易想到,在该连通图中有且仅有一个 u 使得 dfn[u]=low[u]。(感性理解为:相当于想尽量往上走但走不动了,这样的点有且只有一个)

该结点一定是在深度遍历的过程中,该连通分量中第一个被访问过的结点。

感谢理解为:dfn[u]=low[u] 就代表往上走不动了。反之 low[u] 还能被它的儿子更新,那么肯定可以继续往上走到一个更早访问到的节点

另外一种理解是:因为它的 dfn和 low 值最小,不会被该连通分量中的其他结点所影响。

 

因此,在回溯的过程中,判定 dfn[u]=low[u] 是否成立,如果成立,则栈中 u 及其上方的结点构成一个 SCC。

 

简述一下:

1.先复活子树独立性方便处理

2.然后发现一个重要性质,就能求有向图强联通分量了,具体如下:

这就是相当于在树上“剥叶子”,每次找到一个点 $u$,满足 $dfn_u=low_u$,那么 $u$ 所在的 SCC 一定无法往上包括更多点。

所以 $u$ 以及 $u$ 子树内的还没构成 SCC 的点会包括进一个新的 SCC。

缩点


注意到,把有向图强联通的一个部分缩成一个点,最终图一个是一个有向无环图,如果有环,那么肯定能被某个 SCC 包括。

无环往往能方便进行很多操作,比如 拓扑排序+DP(拓扑主要保证了后效性问题)

 

如何缩点?先求所有的 SCC,然后再根据原图的前驱后继关系,建出新图。

这样可能有重边,但是对于正常求 拓扑+DP 是没影响的。 

代码


P3387 【模板】缩点 - 洛谷

我们发现一个环内所有点的权值都可以加上,所以可以先缩点,把环内权值缩到一个点上,再 拓扑+DP。具体的,设 f[u] 表示到 u 点的最大权值。正常转移即可。

#include <bits/stdc++.h>
using namespace std;
const int N=2e4+5;

int n, m, val[N], u1, v1, rt=0, dfn[N], low[N], tim=0, cnt=0, id[N], sum[N], d[N], f[N], ans=0;
vector<int> a[N], b[N];
stack<int> stk;
queue<int> q;
void tarjan(int u)
{
	stk.push(u), dfn[u]=low[u]=++tim;
	
	for (int i=0; i<a[u].size(); i++)
	{
		int v=a[u][i];
		if (!dfn[v])
		{
			tarjan(v);
			low[u]=min(low[u], low[v]);
		}
		else if (!id[v]) low[u]=min(low[u], dfn[v]);
	}
	
	if (dfn[u]==low[u])
	{
		cnt++;
		while (stk.top()!=u) id[stk.top()]=cnt, sum[cnt]+=val[stk.top()], stk.pop();
		id[stk.top()]=cnt, sum[cnt]+=val[stk.top()], stk.pop();
	}
}
void top()
{
	for (int i=1; i<=cnt; i++) if (!d[i]) q.push(i), f[i]=sum[i];
	while (!q.empty())
	{
		int u=q.front();
		q.pop();
		
		for (int i=0; i<b[u].size(); i++)
		{
			int v=b[u][i];
			f[v]=max(f[v], f[u]+sum[v]), d[v]--;
			if (!d[v]) q.push(v);
		}
	}
}
int main() 
{
    scanf("%d%d", &n, &m);
    for (int i=1; i<=n; i++) scanf("%d", &val[i]);
    for (int i=1; i<=m; i++)
    {
    	scanf("%d%d", &u1, &v1);
    	a[u1].push_back(v1);
	}
	for (int i=1; i<=n; i++) if (!dfn[i]) tarjan(i);
	
	for (int u=1; u<=n; u++)
		for (int j=0; j<a[u].size(); j++)
		{
			int v=a[u][j];
			if (id[u]!=id[v]) b[id[u]].push_back(id[v]), d[id[v]]++;
		}
	top();
	
	for (int i=1; i<=cnt; i++) ans=max(ans, f[i]);
	printf("%d", ans);
	return 0;
}

 

 

边双


推导


注意到一点性质:一个边双一定是一个环。(因为没有割边,相当于删去某条边边双内仍然连通,那么再把被删的边加回去发现变成了环)

而找有向图强连通,也是找环。故找无向图的边双约等于找有向图的强连通。

一些不同:

1.无向图无横叉边。所以不用像求强连通那样 ban 掉横叉边(即没有 !id[v] 的判断)

2.无向图求割边不能通过来时的路返回。边双同理。

 

代码


P8436 【模板】边双连通分量 - 洛谷

#include <bits/stdc++.h>
using namespace std;
const int N=5e5+5;

struct node
{
	int v, w;
};
int n, m, u1, v1, rt=0, dfn[N], low[N], tim=0, cnt;
vector<node> a[N];
vector<int> ans[N];
stack<int> stk;
void tarjan(int u, int from)
{
	stk.push(u), dfn[u]=low[u]=++tim;
	
	for (int i=0; i<a[u].size(); i++)
	{
		int v=a[u][i].v, w=a[u][i].w;
		if (!dfn[v])
		{
			tarjan(v, w);
			//可以顺便判割边: if (low[v]>dfn[u]) cut[w]=cut[w^1];
			low[u]=min(low[u], low[v]);
		}
		else if ((w^1)!=from) low[u]=min(low[u], dfn[v]);
	}
	
	if (dfn[u]==low[u])
	{
		cnt++;
		while (stk.top()!=u) ans[cnt].push_back(stk.top()), stk.pop();
		ans[cnt].push_back(stk.top()), stk.pop();
	}
}
int main() 
{
    scanf("%d%d", &n, &m);
    for (int i=1; i<=m; i++)
    {
    	scanf("%d%d", &u1, &v1);
    	a[u1].push_back({v1, i<<1});
    	a[v1].push_back({u1, i<<1|1});
	}
	for (int i=1; i<=n; i++) if (!dfn[i]) tarjan(i, 0);
	
	printf("%d\n", cnt);
	for (int i=1; i<=cnt; i++)
	{
		printf("%d ", ans[i].size());
		for (int j=0; j<ans[i].size(); j++) printf("%d ", ans[i][j]);
		puts("");
	}
    return 0;
}

 

点双


推导


发现一点性质:对于一个点双连通分量,它在 DFS 搜索树中 dfn 值最小的点一定是割点或者树根。

如何证明,分类讨论下。

1.当这个点为割点时,它一定是点双连通分量的根,因为一旦包含它的父节点,他仍然是割点。

2.当这个点为树根时:
a. 有两个及以上子树,它是一个割点。
b. 只有一个子树,它是一个点双连通分量的根。
c. 它没有子树,视作一个点双。

 

所以在遍历 (u,v) 时找发现 u 是割点(即 low[v]>=dfn[u]),那么 u 和 栈中 v 及 v 以上的点构成一个点双。

因为此时不能往 u 父亲方向扩展更多节点,那么此时一定找到了一个极大点双连通分量。

这样的过程是正确的。一种理解(可以结合下图理解):

假定 V 是 v 所属于的点双编号。

判断 u 为割点是回溯时通过 v 判断的,所以 v 子树所有点一定被访问过,且全部在 v 之后访问,也就是栈中 v 往上一直到栈顶都属于 V。

因为是递归往下处理,对于 v 子树内的其余点所构成的另一个点双的情况,在之前往下递归判割点时已经处理掉了。

这相当于每次从树上自底而上剥叶子。

 

需要注意的地方:Tarjan 求点双连通分量的时候,将块出栈时,只能出到 u 的孩子 v 为止。

因为 u 作为割点极有可能是该块与别块公用的,这样若是每次都出栈到 u,则会破坏其他块

 

正因如此,每次弹栈是弹到 u 的孩子 v,如果 u 没有任何一个 v,咋办。特判,此时 u 是一个孤立点,也算一个点双。

代码


P8435 【模板】点双连通分量 - 洛谷

一些细节:

1.注意判自环,图内不应该有自环,不然当有孤立点时会误判该孤立点非点双。

2.可以顺便判割点,但此处懒得再写了。

#include <bits/stdc++.h>
using namespace std;
const int N=5e5+5;

int n, m, u1, v1, dfn[N], low[N], tim=0, cnt=0;
vector<int> a[N], ans[N];
stack<int> stk;
void tarjan(int u)
{
	stk.push(u), dfn[u]=low[u]=++tim;
	if (a[u].size()==0)
	{
		ans[++cnt].push_back(u);
		return ;
	}
	
	for (int i=0; i<a[u].size(); i++)
	{
		int v=a[u][i];
		if (!dfn[v])
		{
			tarjan(v);
			low[u]=min(low[u], low[v]);
			
			if (low[v]>=dfn[u])
			{
				cnt++;
				while (stk.top()!=v) ans[cnt].push_back(stk.top()), stk.pop();
				ans[cnt].push_back(stk.top()), stk.pop();
				ans[cnt].push_back(u);
			}
		}
		else low[u]=min(low[u], dfn[v]);
	}
}
int main() 
{
    scanf("%d%d", &n, &m);
    for (int i=1; i<=m; i++)
    {
    	scanf("%d%d", &u1, &v1);
    	if (u1==v1) continue;
    	a[u1].push_back(v1);
    	a[v1].push_back(u1);
	}
	for (int i=1; i<=n; i++) if (!dfn[i]) tarjan(i);
	
	printf("%d\n", cnt);
	for (int i=1; i<=cnt; i++)
	{
		printf("%d ", ans[i].size());
		for (int j=0; j<ans[i].size(); j++) printf("%d ", ans[i][j]);
		puts("");
	}
    return 0;
}

  

 

应用


1.有向图缩点后是一个 DAG。可以理解为若干条链组成的图

2.边双缩点后是一颗树,可以方便考虑很多树上问题

 

例题


水了好多题。。。比较水的题就不贴代码了。

 

板子类


求有向图强连通分量:B3609,P1726
求有向图点数大于 1 的强连通分量个数:P2863

求割点:UVA315

求点双:B3610

 

分类讨论


P5058 ,一句话题意:找两点之间的割点。

根据 dfn序 分类讨论节点位置,题解:嗅探器(割点)

一种错误方法,这里详细展开:

以 a 点跑 tarjan 处理出每个节点的 low 值,以及 dfn序 相对应的节点 p[u],然后不断去找 y 到 a 的路径上 y 必须经过的点,也就是 p[low[y]]。

此时就可以在此处安装一个嗅探器。然后继续往该必经点的必经点跳。

#include <bits/stdc++.h>
using namespace std;
const int N=2e5+5;

int n, u1, v1, dfn[N], low[N], tim=0, x, y, p[N], ans=0;
vector<int> a[N];
void tarjan(int u)
{
	dfn[u]=low[u]=++tim, p[tim]=u;
	for (int i=0; i<a[u].size(); i++)
	{
		int v=a[u][i];
		if (!dfn[v])
		{
			tarjan(v);
			low[u]=min(low[u], low[v]);
		}
		else low[u]=min(low[u], dfn[v]);
	}
}
int main()
{
	scanf("%d", &n);
	while (1)
	{
		scanf("%d%d", &u1, &v1);
		if (u1==0 && v1==0) break;
		a[u1].push_back(v1);
		a[v1].push_back(u1);
	}
	scanf("%d%d", &x, &y);
	tarjan(x);
	
	ans=-1;
	int t=p[low[y]];
	if (t!=y && t!=x) ans=t;
	while (low[t]!=dfn[t])
	{
		t=p[low[t]];
		if (t!=y && t!=x) ans=min(ans, t);
	}
	if (ans==-1) printf("No solution");
	else printf("%d", ans);
	return 0;
}

  

这就是没有正确理解 low 含义才打出来的代码。

low 仅仅代表往下走若干条树边,再走一条返祖边。仅仅是用于判割点/割边而建立的,而不是必经点。

只有割点才是必经点,但是 low 指向的点不是必经点,只是辅助于算 割点/割边 的。

也就是对于此题。我不一定走某条返祖边到 low,我还可以走向上的父亲的树边。

hack:

in:
6
3 1
5 1
4 3
2 6
1 6
6 4
4 5
0 0
6 5

out:
No solution

 

 

缩点后解决一类与连通性有关的问题


有向图:

有向图缩点后变成了DAG,就可以以较低的复杂度解决一类可达性问题。

P1407 [国家集训队] 稳定婚姻 - 洛谷Code),此题应该是解决了两点是否在同一强联通内的问题,看这篇题解:朋友

 

 

缩点后统计度,解决一类与度数有关的问题


有向图:

P2341Code),缩点后统计出度。但是很多 corner 注意。

P2746Code),缩点后类比二分图贪心的想,也是用入度出度去想即可。

考虑所有入度为 0 的点顺次相连后,再连向出度为 0 的点一条边。对于出度为 0 的点同样的考虑方法。

 

无向图:

边双缩点后变成一颗树,用割边相连。但是缩点如何处理重边?因为是边双用割边相连,我们记录割边,然后利用记录的割边缩点即可。

P2860Code)此题是边双缩点板子,建议看看如何处理重边。一个经典套路:无向图加 (缩点后叶子结点个数+1)/2 条边可以变成边双连通图。

为什么?可以发现最终每个点的度都为至少 2,那么只要度为 1 的点两两相连即可满足条件且最小化边数。

 

缩点后变成DAG,解决一类DP问题


有向图缩点后变成了DAG,就可以用拓扑+DP求解问题。

但是要注意。如果给定起点终点求问题,要先按起点 dfs 一遍,把无法到达的点删去,再建一张新图跑 拓扑序。

(因为总不能直接把起点加入再拓扑吧,也不能从起点 dfs,会被卡成 n^2,只能用拓扑 or 其他算法)

 为什么不能把起点直接加入队列再拓扑?因为有些点起点不一定能到达,导致你直接加入起点后拓扑,有些点的度无法被删除成 0,最后出错

hack(SP14887):

in:
3 3 1 2
1 2 3
1 2
3 1
3 2

out:
3

  

P3387Code),SP14887Code),P3627Code)缩点后跑 dp 处理最远距离即可。后一题还可以跑最短路。

P1073,根据题意 DP。注意一下不要少情况。题解:题解:P1073 - 洛谷专栏 (luogu.com.cn)

P10944Code),转换题意后变成要判整张图是否为一条链,只用度来判不行,因为有重边,考虑 DP,记录最长链长度就好,看看最后是不是等于结点个数。

 

P2515 Code),这题缩点后整个图会变成一颗树,此时不需要拓扑了,因为拓扑就是为了确保 DP 有正确顺序,此时直接树形 DP 就是正确顺序了,也就是树上背包。

 

计数类


P3469 Code),考虑一个点如果为割点,就会分成多个连通块,然后可以在边处理割点的时候边统计答案。

就是统计答案比较麻烦,可能会有重复,于是钦定每对 (x, y) 只算 1 次贡献,而不算 (y, x) 的这对贡献,最后再 *2 把贡献补回来。

也可以是算 2 次,最后对算出的总贡献推一推式子,补/减去一些少算的/多余的贡献。

判割点会单独处理根,此题是用 dfs生成树 统计答案,不能特别处理根,因为可能有的边通过返祖边直接到根,那么根就有很多相连的 "儿子" 了,此时再算贡献就会出错。实际上根的真实儿子是第一次访问到的点。

 

P2272 Code)和 P10944 比较像,发现半联通子图就是一条链,于是转换成求最长路+计数(其实这里就是最长链计数)。

 

杂题 


P2783Code),点双缩点后跑 lca,比较板了。

 

 

参考文献


1.图论 I - 洛谷专栏(包括 从动态规划的角度理解 Tarjan 算法 - 洛谷专栏

2.初探tarjan算法(求强连通分量) - 洛谷专栏

4.强连通分量 - OI Wiki

5.我之前的 blog(主要参考了代码)

 
 
 
 

 

posted @ 2025-06-08 11:21  cn是大帅哥886  阅读(20)  评论(0)    收藏  举报