蒟蒻心得:Tarjan算法非常晦涩难懂,咬着牙啃下来也才初窥门道,对于各位大佬来说应该是小case(ORZ!!!!!)。下面我讲讲我对Tarjan算法的理解。
一、Tarjan算法概述
Tarjan算法是一种求解有向图中强连通分量的经典算法,其核心思想是基于深度优先搜索,来求有向图的强连通分量的算法。
下列介绍一下Tarjan算法的一些变量:
变量名 | 变量类型 | 变量作用 |
g[1010] | vector<int>> | 用于构造图,存每个顶点对应得边 |
st | stack<int> | 用于在深度优先搜索得时候存访问的节点 |
b[1010] | bool | 状态数组,用于判断对于顶点是否入栈 |
temp | int | 时间戳,用于给顶点分配次序号 |
dfn[1010] | int | 节点被搜索的次序号数组,用于记录节点是第几个被深度优先搜索访问的 |
low[1010] | int | 节点i能够回溯到的最早放入栈中的节点的次序号(就是节点i经过遍历能够到达的最早放入栈中节点的次序号) |
sccs | vector<vector<int> > | 用于存储强连通分量 ,一个强连通分量就是一个vector<int> |
下面简单介绍一下Tarjan算法的思路,首先我们从第一个顶点 x 进行深度优先搜索,然后给这个顶点的dfn数组和low数组进行初始化,初始时都为temp,然后让temp++(如果temp=0,则为++temp),将x入栈,状态数组 b赋值为真,表示栈里存在顶点x。然后对x的所有的邻接点进行遍历(和x有边的顶点),如果这个顶点没有被访问过(一般来说,dfn[ti]==0则是没有被访问过),那么对这个顶点进行深度优先搜索。搜索完之后,这个顶点的low[ti]一定会得到更新,很容易可以理解,就是low[ti]表示顶点i可以到达的最早放入栈中节点的次序号,既然ti可以到达,因为x可以到ti,所以x也可以到达low[ti]。所以low[x]=min(low[x],low[ti])。这是搜索完ti后要做的操作。如果这个点已经放入了栈中呢,那么这个点的dfn[ti]一定小于dfn[x],所以low[x]原来是dfn[x],现在应该是min(low[x],dfn[ti])。如果这个顶点被访问过,并且已经不在栈内了,说明这个节点已经被处理了,并且它所属的强连通分量已经被确定了,即已经得到了它对应的强连通分量。
上述操作完成后,就可以进行强连通分量的判定了,如果一个节点的dfn[x]==low[x],说明这个节点是一个强连通分量的根节点。因为这个节点它到自己就是栈内最早放入的节点。我们直到dfn[x]分配好是不进行更新的,如果dfn[x]==low[x],说明它无法到任何其他的顶点比它到栈更早,我们知道后面加入的所有顶点都是栈前面的顶点可达的顶点,所以如果其他顶点可达x,x也可达其它顶点,那么就得到了一个强连通分量。
核心思路总结
Tarjan 算法基于深度优先搜索(DFS)来寻找有向图中的强连通分量(SCC)。其核心在于利用两个关键数组
dfn
和low
,以及一个栈来辅助完成计算。具体步骤
1. 初始化
- 从图中任意一个未访问的顶点
x
开始进行深度优先搜索。- 为
x
分配dfn
和low
值,初始值为当前时间戳temp
,随后temp
自增。- 将
x
压入栈中,并标记x
在栈内(b[x] = true
)。2. 邻接点处理
- 未访问的邻接点
ti
:若dfn[ti] == 0
,说明ti
未被访问过,对其进行深度优先搜索。搜索完成后,由于x
能到达ti
,而low[ti]
表示ti
能到达的最早入栈节点的次序号,所以更新low[x] = min(low[x], low[ti])
。- 已在栈内的邻接点
ti
:若ti
已在栈中,此时dfn[ti] < dfn[x]
,更新low[x] = min(low[x], dfn[ti])
。- 已访问但不在栈内的邻接点
ti
:若ti
已被访问且不在栈内,说明ti
所属的强连通分量已确定,无需对low[x]
进行更新。3. 强连通分量判定
- 当
dfn[x] == low[x]
时,意味着x
是一个强连通分量的根节点。因为x
无法到达比它更早入栈的节点,且栈中x
之上的节点都能与x
相互可达,所以从栈顶到x
的所有节点构成一个强连通分量。将这些节点从栈中弹出,它们就组成了一个独立的强连通分量。复杂度分析
- 时间复杂度:O(V+E),其中 V 是图的顶点数,E 是图的边数。因为每个顶点和每条边都只会被访问一次。
- 空间复杂度:O(V),主要用于存储
dfn
、low
数组和栈。
模板代码实现:
对于下列有向图,求强连通分量
1 -> 2
2 -> 3, 4
3 -> 1
4 -> 5
5 -> 4
#include<iostream>
#include<algorithm>
#include<vector>
#include<queue>
#include<stack>
using namespace std;
#define endl '\n'
#define int long long
#define IOS ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
#define inf 0x3f3f3f3f
#define pii pair<int,int>
#define mii map<int,int>
//tarjan板子试写
//用邻接表存储 图
int n, m; //顶点数 和 边数
vector<int> g[1010];//构造图
int dfn[1010];//搜索的次序编号
int low[1010];//结点i能够回溯到的最早的栈中节点的次序号
bool b[1010];//是否入栈
vector<vector<int> > sccs; //scc强连通分量。用于存储图中所有的强连通分量
int temp = 0;//时间戳
stack<int> st; //栈,用来装被访问过的点
void tarjan(int x) {
dfn[x] = low[x] = ++temp; //对于每个顶点,最开始访问时,搜索次序和最早能回溯的栈内次序号,都等于=时间戳
b[x] = true;
st.push(x);
//访问这个顶点的所有边
for (int i = 0; i < g[x].size(); i++) {
int ti = g[x][i];
if (dfn[ti] == 0) {
tarjan(ti); //如果这个节点的未被访问过,深搜访问这个顶点,回溯回来的时候,再更新x可以回溯的最早栈中节点的次序号
low[x] = min(low[x], low[ti]);
}
else { //如果访问过
//如果在栈里
if (b[ti]) low[x] = min(low[x], dfn[ti]); //如果已经加入栈中,更新x可以回溯的最早栈中节点的次序号
}
}
if (dfn[x] == low[x]) {
//说明x是一个强连通分量的顶点
vector<int> scc;
int u;
do {
u = st.top();
st.pop();
scc.push_back(u);
b[u] = false;
} while (u != x);
sccs.push_back(scc);
}
}
signed main() {
cin >> n >> m;
while (m--) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
}
//这里需要特别注意的是,如果这个图是非强连通图,从一个顶点可能访问不了全部的顶点
//所以要循环遍历,对于所有的没有访问的顶点进行一次深搜tarjan(i)
for (int i = 1; i <= n; i++) {
if(dfn[i]==0)
tarjan(i);
}
for (int i = 0; i < sccs.size(); ++i) {
cout << "第 " << i + 1 << " 个强连通分量: ";
for (int v : sccs[i]) {
cout << v << " ";
}
cout << endl;
}
return 0;
}
/*
5 6
1 2
2 4
4 5
5 4
2 3
3 1
*/
注意要从图中任意一个没有访问过的顶点开始进行tarjan深度优先搜索,因为图不一定是强连通图,只从一个顶点出发,可能不能遍历所有的顶点。
//这里需要特别注意的是,如果这个图是非强连通图,从一个顶点可能访问不了全部的顶点
//所以要循环遍历,对于所有的没有访问的顶点进行一次深搜tarjan(i)
for (int i = 1; i <= n; i++) {
if(dfn[i]==0)
tarjan(i);
}
运行结果:
二、Tarjan用于解决强连通分量问题
1、缩点(缩点+拓扑+动态规划)
在有向图中,将每个强连通分量缩成一个点,得到一个新的有向无环图(DAG),然后在 DAG 上进行后续的处理,如拓扑排序、动态规划等。这种方法可以将复杂的有向图转化为更简单的结构,便于解决问题。
AI模板(供参考)
#include <iostream>
#include <vector>
#include <stack>
#include <algorithm>
#include <queue>
using namespace std;
const int MAXN = 10005;
// 邻接表存储图
vector<int> graph[MAXN];
// 缩点后的新图
vector<int> newGraph[MAXN];
// dfn 数组记录节点的深度优先搜索编号
int dfn[MAXN];
// low 数组记录节点的追溯值
int low[MAXN];
// 标记节点是否在栈中
bool inStack[MAXN];
// 栈用于存储访问过的节点
stack<int> st;
// 时间戳,用于分配 dfn 编号
int timestamp = 0;
// 存储所有强连通分量
vector<vector<int>> sccs;
// 记录每个节点所属的强连通分量编号
int sccId[MAXN];
// 节点的权值
int weight[MAXN];
// 缩点后每个强连通分量的权值
int newWeight[MAXN];
// 入度数组,用于拓扑排序
int inDegree[MAXN];
// 动态规划数组
int dp[MAXN];
// Tarjan 算法核心函数
void tarjan(int u) {
dfn[u] = low[u] = ++timestamp;
st.push(u);
inStack[u] = true;
for (int v : graph[u]) {
if (!dfn[v]) {
// v 未被访问过,递归访问 v
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (inStack[v]) {
// v 已经在栈中,更新 low[u]
low[u] = min(low[u], dfn[v]);
}
}
if (dfn[u] == low[u]) {
// u 是一个强连通分量的根节点
vector<int> scc;
int v;
do {
v = st.top();
st.pop();
inStack[v] = false;
scc.push_back(v);
sccId[v] = sccs.size();
} while (v != u);
sccs.push_back(scc);
}
}
// 缩点操作,构建新图
void shrink() {
int n = sccs.size();
for (int i = 0; i < n; ++i) {
for (int u : sccs[i]) {
newWeight[i] += weight[u];
for (int v : graph[u]) {
if (sccId[u] != sccId[v]) {
newGraph[sccId[u]].push_back(sccId[v]);
inDegree[sccId[v]]++;
}
}
}
}
}
// 拓扑排序 + 动态规划
int topoSortAndDP() {
int n = sccs.size();
queue<int> q;
for (int i = 0; i < n; ++i) {
if (inDegree[i] == 0) {
q.push(i);
dp[i] = newWeight[i];
}
}
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : newGraph[u]) {
dp[v] = max(dp[v], dp[u] + newWeight[v]);
if (--inDegree[v] == 0) {
q.push(v);
}
}
}
int ans = 0;
for (int i = 0; i < n; ++i) {
ans = max(ans, dp[i]);
}
return ans;
}
int main() {
int n, m;
cout << "请输入节点数和边数: ";
cin >> n >> m;
cout << "请输入每个节点的权值: ";
for (int i = 1; i <= n; ++i) {
cin >> weight[i];
}
cout << "请输入每条边的起点和终点: " << endl;
for (int i = 0; i < m; ++i) {
int u, v;
cin >> u >> v;
graph[u].push_back(v);
}
// 进行 Tarjan 算法找出强连通分量
for (int i = 1; i <= n; ++i) {
if (!dfn[i]) {
tarjan(i);
}
}
// 缩点操作
shrink();
// 拓扑排序 + 动态规划
int result = topoSortAndDP();
cout << "从某个起始点出发,经过路径上所有点的权值之和的最大值为: " << result << endl;
return 0;
}
见下列例题:
题目描述
给出 N 个点,M 条边的有向图,对于每个点 v,求 A(v) 表示从点 v 出发,能到达的编号最大的点。
输入格式
第 1 行 2 个整数 N,M,表示点数和边数。
接下来 M 行,每行 2 个整数 Ui,Vi,表示边 (Ui,Vi)。点用 1,2,…,N 编号。
输出格式
一行 N 个整数 A(1),A(2),…,A(N)。
输入输出样例
输入 #1复制
4 3 1 2 2 4 4 3
输出 #1复制
4 4 3 4
说明/提示
- 对于 60% 的数据,1≤N,M≤103。
- 对于 100% 的数据,1≤N,M≤105。
搁着,等我完成拓扑学习,再来补充
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define int long long
#define IOS ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
#define inf 0x3f3f3f3f
#define pii pair<int,int>
#define mii map<int,int>
//缩点+逆向拓扑+DP
//先利用tarjan算法进行缩点shrink,然后逆向拓扑进行dp求能达到的遍历最大的值
vector<int> g[100100];//构造图
vector<int> gg[101000]; //构造缩点后新的图
vector<vector<int> > sccs;//存强连通分量,也可以作为缩点后的顶点
bool isstack[100100]; //用于判断是否在栈里面
stack<int> st; //栈
int dfn[100100]; //用于存顶点i进行搜索时的次序编号
int low[100100]; //用于存顶点i能够达到的最早入栈的栈内顶点次序号
int temp=0; //时间戳,用于分配次序编号
int f[100100];//记录每个顶点缩点后的顶点号
int outdegree[100100]; //记录每个顶点的出度
int dp[100100]; //记录缩点后的顶点能达到最大顶点的编号
bool ispos[100010];
int n,m;
void tarjan(int x){
low[x] = dfn[x] =++temp;
st.push(x);
isstack[x]=true;
for(int i=0;i<g[x].size();i++){
int ti=g[x][i];
if(dfn[ti]==0) //说明顶点ti还没有被访问过
{
tarjan(ti); //对ti进行搜索后,回溯时,ti的low[ti]一定的得到了更新
low[x]=min(low[x],low[ti]); //所以对x的low[x]进行更新
}
else{ //如果顶点ti还没被访问过
if(isstack[ti]) low[x]=min(low[x],dfn[ti]); //如果顶点ti在栈内,x可以访问ti,那么就可以更新low[x]最少可以访问到ti的次序编号
//否则,如果ti不在栈内,但是ti被访问过,说明ti已经确认了自己的强连通分量,并且已经出栈
}
}
vector<int> scc;//一个强连通分量
if(dfn[x]==low[x]){ //说明x是一个强连通分量的顶点
int u;
do{
u=st.top();
st.pop();
scc.push_back(u);
isstack[u]=false;
}while(u!=x); //直到把x放入强连通分量数组内,结束
sccs.push_back(scc);
}
}
//进行缩点操作
void shrink(int n){
//把每个强连通分量的下标作为一个顶点,顶点编号用这个强连通分量里的最大顶点编号代替
for(int i=0;i<sccs.size();i++){
int val=0;
for(int x: sccs[i]){
val=max(val,x);
}
ispos[val]=true;//我用一个数组来记录缩点后的点
for(int x: sccs[i]){
f[x]=val; //x顶点缩点后属于val顶点,因为val顶点最大
}
}
//构造新的图:遍历所有的边,将他们按照缩点的顶点重新构图
//为了方便后续进行逆向拓扑,这里需要记录出度
for(int i=1;i<=n;i++){
for(int x:g[i]){
int fi=f[i];
int fx=f[x];
if(fi!=fx){ //必须要是两个顶点不在一个强连通分量,才能进行加边操作!!!!!!
outdegree[fi]++;
gg[fx].push_back(fi);
}
}
}
}
//最后一步,根据逆拓扑排序,找出度为0的顶点,没有顶点大小要求,所以用队列即可
void topsort_dp(int n){ //这里的顶点个数还是原来的n,只是缩点进行操作,没有缩小大小
queue<int> q;
for(int i=1;i<=n;i++){
if(ispos[i]){
dp[i]=i; //最小能访问到自己
if(outdegree[i]==0)
q.push(i);
}
}
while(!q.empty()){
int si=q.front();
q.pop();
for(int i=0;i<gg[si].size();i++){
int u=gg[si][i];
outdegree[u]--;
dp[u]=max(dp[u],dp[si]);//如果能达到si,那么也能达到si能达到的最大值
if(outdegree[u]==0) q.push(u);
}
}
}
signed main(){
cin>>n>>m;
while(m--){
int a,b;
cin>>a>>b;
g[a].push_back(b);
}
for(int i=1;i<=n;i++){
if(dfn[i]==0){
tarjan(i);
}
}
shrink(n);//缩点
topsort_dp(n);
//dp[f[i]]
for(int i=1;i<=n;i++){
int fi=f[i];
cout<<dp[fi];
if(i!=n) cout<<' ';
}
}
题目描述
给定一个 n 个点 m 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。
允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。
输入格式
第一行两个正整数 n,m
第二行 n 个整数,其中第 i 个数 ai 表示点 i 的点权。
第三至 m+2 行,每行两个整数 u,v,表示一条 u→v 的有向边。
输出格式
共一行,最大的点权之和。
输入输出样例
输入 #1复制运行
2 2 1 1 1 2 2 1
输出 #1复制运行
2
说明/提示
对于 100% 的数据,1≤n≤104,1≤m≤105,0≤ai≤103。
- 2024-11-1 添加了 hack 数据;
AC代码:
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define int long long
#define IOS ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
#define inf 0x3f3f3f3f
#define pii pair<int,int>
#define mii map<int,int>
//PS:这不是关键路径吗!!!
//关键路径
//缩点+逆向拓扑+DP
//先利用tarjan算法进行缩点shrink,然后逆向拓扑进行dp求能达到的遍历最大的值
vector<int> g[100100];//构造图
vector<int> gg[101000]; //构造缩点后新的图
vector<vector<int> > sccs;//存强连通分量,也可以作为缩点后的顶点
bool isstack[100100]; //用于判断是否在栈里面
stack<int> st; //栈
int dfn[100100]; //用于存顶点i进行搜索时的次序编号
int low[100100]; //用于存顶点i能够达到的最早入栈的栈内顶点次序号
int temp=0; //时间戳,用于分配次序编号
int f[100100];//记录每个顶点缩点后的顶点号
int outdegree[100100]; //记录每个顶点的出度
int dp[100100]; //记录缩点后,以顶点i为起点的路径的最大长度(最大点权值)
int w[100100];//原图顶点的权值
int ww[100100];//缩点后顶点的权值
int n,m;
void tarjan(int x){
low[x] = dfn[x] =++temp;
st.push(x);
isstack[x]=true;
for(int i=0;i<g[x].size();i++){
int ti=g[x][i];
if(dfn[ti]==0) //说明顶点ti还没有被访问过
{
tarjan(ti); //对ti进行搜索后,回溯时,ti的low[ti]一定的得到了更新
low[x]=min(low[x],low[ti]); //所以对x的low[x]进行更新
}
else{ //如果顶点ti还没被访问过
if(isstack[ti]) low[x]=min(low[x],dfn[ti]); //如果顶点ti在栈内,x可以访问ti,那么就可以更新low[x]最少可以访问到ti的次序编号
//否则,如果ti不在栈内,但是ti被访问过,说明ti已经确认了自己的强连通分量,并且已经出栈
}
}
vector<int> scc;//一个强连通分量
if(dfn[x]==low[x]){ //说明x是一个强连通分量的顶点
int u;
do{
u=st.top();
st.pop();
scc.push_back(u);
isstack[u]=false;
}while(u!=x); //直到把x放入强连通分量数组内,结束
sccs.push_back(scc);
}
}
//进行缩点操作
void shrink(int n){
//把每个强连通分量的下标作为一个顶点
for(int i=0;i<sccs.size();i++){
for(int x: sccs[i]){
f[x]=i; //x顶点缩点后属于i顶点
ww[i]+=w[x]; //i的权值是所有x顶点权值之和
}
}
//构造新的图:遍历所有的边,将他们按照缩点的顶点重新构图
//为了方便后续进行拓扑,这里需要记录出度(一般都用逆向拓扑)
for(int i=1;i<=n;i++){
for(int x:g[i]){
int fi=f[i];
int fx=f[x];
//构图前进行判断他们不是同一个点
if(fi!=fx){
outdegree[fi]++;
// gg[fi].push_back(fx);
gg[fx].push_back(fi);//逆向构图:其实这题正向也行,几乎完全一样
}
}
}
}
//最后一步,根据逆拓扑排序,找出度为0的顶点,没有顶点大小要求,所以用队列即可
void topsort_dp(int n){ //这里的顶点个数是缩点后的顶点个数:sccs.size();
queue<int> q;
for(int i=0;i<n;i++){
if(outdegree[i]==0)
{
q.push(i);
dp[i]=ww[i];
}
}
while(!q.empty()){
int si=q.front();
q.pop();
for(int i=0;i<gg[si].size();i++){
int ti=gg[si][i];
dp[ti]=max(dp[ti],ww[ti]+dp[si]);
outdegree[ti]--;
if(outdegree[ti]==0) q.push(ti);
}
}
}
signed main(){
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>w[i];
while(m--){
int a,b;
cin>>a>>b;
g[a].push_back(b);
}
for(int i=1;i<=n;i++){
if(dfn[i]==0)
tarjan(i);
}
shrink(n);
topsort_dp(sccs.size());
int ans=0;
for(int i=0;i<sccs.size();i++){
ans=max(ans,dp[i]);
}
cout<<ans<<endl;
}
PS:补充一点注意事项,因为在写上面两题模板时出现了很多小错误。
对于缩点操作
我们在缩点操作中,对于进行更新的点,我们可以得到他们的强连通分量编号,我们需要对他的强联通分量进行比较,如果不等于,说明他们不在同一个强连通分量内,我们才能进行缩点后的加边操作。
//构造新的图:遍历所有的边,将他们按照缩点的顶点重新构图(逆向拓扑) for(int i=1;i<=n;i++){ for(int x:g[i]){ int fi=f[i]; int fx=f[x]; if(fi!=fx){ //必须要是两个顶点不在一个强连通分量,才能进行加边操作!!!!!! outdegree[fi]++; gg[fx].push_back(fi); } } }
对于Tarjan操作
注意,因为图并不一定是强连通图,所以我们不一定能够一次访问全部的点,所以需要遍历所有的边,如果这个点没有被访问过就进行一次深度优先搜索。
//这里需要特别注意的是,如果这个图是非强连通图,从一个顶点可能访问不了全部的顶点 //所以要循环遍历,对于所有的没有访问的顶点进行一次深搜tarjan(i) for (int i = 1; i <= n; i++) { if(dfn[i]==0) tarjan(i); }