若已学过《Tarjan 强连通分量 (SCC):时间戳、回溯与出栈》,可直接从第五节选学 2-SAT;第一至四节保留作独立阅读时的复习。
Tarjan 算法关注的是有向图的连通性分析与拓扑缩点(识别并提取图中的环状结构)。
Tarjan 算法依赖的是深度优先搜索树 (DFS Tree) 与时间戳回溯(通过记录节点的访问顺序和可达的最高祖先来判定环的边界)。
Tarjan 算法的核心思想是栈维护与状态隔离(暂存已经访问、但还没归入某个 SCC 的节点;确认一个完整分量后,再统一出栈)。
一、核心定义与问题背景
在无向图中,连通性是双向的。但在有向图中,存在边
- 强连通 (Strongly Connected):在有向图中,如果节点
和节点 之间能够互相到达,则称 和 是强连通的。 - 强连通分量 (Strongly Connected Component, SCC):有向图中的一个极大子图,在这个子图内的任意两个节点都能互相到达。单独的一个节点也被视为一个强连通分量。
先抓住重点: 同一个 SCC 内任意两点都能“有去有回”;两个不同的 SCC 之间不能互相到达,否则它们就该合成一个更大的 SCC。
算法应用背景(缩点):
拓扑排序和按拓扑序转移的 DP,需要有向无环图 (DAG)。原图有环时,拓扑排序就排不完,我们得先把这些互相依赖的部分处理掉。
Tarjan 算法的通常目的是将每一个强连通分量“压缩”成一个单一的“超级节点”(即缩点)。缩点后的新图必然是一个 DAG,从而可以将问题转化为常规的 DAG 上动态规划问题。
通俗类比: “城市交通圈的合并”。
如果几个城市之间有单向高速公路形成了一个完整的闭环,车辆可以在这几个城市间无限循环穿梭。在进行宏观路径规划时,我们可以将这几个形成闭环的城市视为一个巨大的“都市圈”(超级节点),从而简化整个国家的交通网络。
二、核心原理与状态转移逻辑
Tarjan 算法本质上是对图进行了一次深度优先搜索(DFS)。在搜索过程中,维护以下三个核心状态:
1. 状态数组的严格数学定义
dfn[u](DFS Number / 时间戳):表示节点在 DFS 过程中首次被访问的次序。该值一旦分配,永不改变。 low[u](追溯值):表示从节点出发,先沿 DFS 树向下走零步或多步,再通过至多一条指向当前搜索栈内节点的边,所能触及的最小 dfn。它会在回溯时不断由子节点向父节点传递。
2. 状态转移方程
在遍历节点
-
树边(
未被访问过): 此时
dfn[v] == 0,我们递归向下搜索dfs(v)。回溯时,用子树的追溯值更新当前节点: -
返祖边/横叉边(
被访问过,且仍在栈中): 此时到达了一个尚未出栈的节点,用它的时间戳更新。它可能是祖先,也可能已经结束递归、但仍在等待分量归属;Tarjan 的栈不只是当前递归路径。
-
废弃边(
被访问过,但已不在栈中): 说明
属于一个已经处理完毕并输出的强连通分量,与当前节点所在的分量无关。直接忽略,不进行状态转移。
3. 强连通分量的识别与出栈
当节点
如果相等,说明这一支不能再联系到比
此时,栈中位于
课堂手推: 画出
1→2→3→1,再从 3 连一条边到 4。按访问顺序记录dfn、low和栈,看看为什么 4 先单独出栈,1、2、3 随后一起出栈。

三、标准求解算法与模板
易错点分析: 在判断已访问节点时,严格使用 else if(instk[v])。如果不判断目标节点是否仍在栈内,横叉边可能会将 low[u] 错误地更新为其他已经隔离的 SCC 的时间戳,导致算法逻辑完全崩溃。
下面的模板只求 scc 编号和 sz 大小,输出部分按具体题目补充。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 100005;
vector<int> node[N];
// 核心状态数组
int dfn[N]; // 时间戳
int low[N]; // 追溯值
int instk[N]; // 标记节点是否在栈中
int scc[N]; // 记录节点 u 所属的强连通分量编号
int sz[N]; // 记录第 i 个强连通分量包含的节点个数
int id = 0; // 全局时间戳分配器
int sc = 0; // 强连通分量计数器
stack<int> stk; // 保存已经访问、但尚未归入 SCC 的节点
void dfs(int u) {
// 1. 初始化当前节点的状态并入栈
dfn[u] = low[u] = ++id;
stk.push(u);
instk[u] = 1;
// 2. 遍历相邻节点,进行状态转移
for (int v : node[u]) {
if (!dfn[v]) {
// 情况 A:v 未被访问,产生树边,向下递归
dfs(v);
low[u] = min(low[u], low[v]);
} else if (instk[v]) {
// 情况 B:v 已经被访问且在栈中,产生返祖边或有效的横叉边
low[u] = min(low[u], dfn[v]);
}
// 若 v 已访问且不在栈中(instk[v] == 0),属于情况 C,直接忽略
}
// 3. 识别强连通分量根节点,执行出栈隔离
if (dfn[u] == low[u]) {
sc++; // 产生一个新的强连通分量
int x = 0;
do {
x = stk.top();
stk.pop();
instk[x] = 0; // 标记出栈
scc[x] = sc; // 染色:记录节点所属的 SCC 编号
sz[sc]++; // 统计该 SCC 的节点数量
} while (x != u); // 直到将根节点 u 自身也弹出
}
}
signed main() {
ios::sync_with_stdio(0), cin.tie(0);
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
node[u].push_back(v);
}
// 图可能是不连通的,必须遍历每一个节点作为潜在的搜索起点
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
dfs(i);
}
}
return 0;
}
四、洛谷实战演练与“缩点”延伸
Tarjan 算法求出 SCC 后,最常规的操作是“缩点建新图”。
缩点方法:遍历原图的所有边
1. 💡 基础应用:出度/入度分析 (洛谷 P2341 受欢迎的牛)
-
问题背景:
头牛有单向的仰慕关系。仰慕关系具有传递性。求被所有牛都仰慕的牛的数量。 -
算法分析:
原图中可能存在环,将环利用 Tarjan 算法缩点。在缩点后的 DAG 中,每个超级节点沿出边继续走,最终都会到达某个出度为
的超级节点;如果这样的超级节点只有一个,那么所有节点最终都能到达它,而它无法再走向其他分量(即被所有人仰慕,且处于拓扑排序的底端)。 注意事项:如果缩点后的 DAG 中存在两个或以上出度为
的节点,则不存在被所有人仰慕的牛。若只有一个出度为 的超级节点,答案即为该超级节点内包含的原节点个数( sz数组)。
2. 💡 进阶应用:缩点 + DAG 上的动态规划 (洛谷 P3387 【模板】缩点)
-
问题背景:给定一个有向图,每个节点有权值。求一条路径,使得路径经过的节点权值之和最大。经过多次的节点权值只计算一次。
-
算法分析:
因为经过多次只计算一次,所以如果走进了一个环,我们一定可以把这个环里的所有节点都走一遍,获取它们的全部权值。
- 使用 Tarjan 算法求出所有的强连通分量。
- 统计每个 SCC 的总权值(即内部所有节点的权值和)。
- 执行缩点操作,建立一张全新的 DAG。
- 在 DAG 上运用拓扑排序,进行常规的动态规划求解最长路径即可:
。
开始时令每个分量的
dp[u]=weight[u],表示允许从它自己出发;按拓扑序转移后,答案取所有dp[u]的最大值。重边可以保留,只要邻接表存几次、入度就加几次,处理时也减几次,二者保持一致即可。
// 缩点建新图的核心代码片段(假设新建的图存入 new_node 数组,入度存入 in_degree)
for (int u = 1; u <= n; u++) {
for (int v : node[u]) {
if (scc[u] != scc[v]) {
new_node[scc[u]].push_back(scc[v]);
in_degree[scc[v]]++;
}
}
}
五、2-SAT 问题与逻辑约束的图论降维(选学)
1. 问题背景:非黑即白的抉择
很多时候,我们面临的不是在图中找路径,而是做一系列“二选一”的逻辑决策。
比如有
2-SAT (2-Satisfiability) 问题就是指:所有的约束条件都可以转化成两个变量的“或”关系(析取)。我们需要给每个变量分配真 (1) 或假 (0),让所有条件同时满足。如果能,给出一种方案;如果不能,立刻判定无解。
2. 状态分配与建图:寻找“蕴含边”
既然每个同学
- 真节点
:代表第 个同学去。 - 假节点
:代表第 个同学不去。(代码中通常设为 )
核心推导:将任何逻辑关系拍扁成有向推导边 (蕴含边)。
在 2-SAT 中,有向边
我们拿最经典的约束“同学 A 或 同学 B 至少去一人 (
- 如果 A 不去,为了满足条件,B 必须去。连边:
。 - 如果 B 不去,为了满足条件,A 必须去。连边:
。 这两条边互为逆否命题,必须成对添加。
如果约束是“A 必须去”怎么连?可以视为
- 如果 A 不去,A 必须去。连边:
。这就暗示了走上 是绝路,顺着边终将导向矛盾或被迫折返至 A。
3. 判断矛盾:强连通分量 (SCC) 的判决
有向图建好后,图中的一条路径
如果图中出现了环,这意味着环上的状态“同生共死”:只要选中其中一个,就必须同时选中其余状态;也可以全部不选。这正是 SCC 的物理意义!
那么什么时候会无解(自相矛盾)?
如果变量
这就意味着:图中存在一条路从
4. 赋值方向:Tarjan 编号的隐藏福利
既然有解,我到底该让变量
重点来了:我们不需要手写拓扑排序!
回忆一下 Tarjan 的出栈机制:只有当一个 SCC 内部及其所有能到达的下游分支都搜索完后,这个 SCC 的根才会被判定出栈。
这意味着,越先出栈的 SCC,位于拓扑序的越末端。
也就是说,scc[x] 的编号越小,说明它越早出栈,拓扑序越靠后!
所以,对变量
- 如果
scc[i] < scc[i']:真节点的拓扑序更靠后,更安全,所以赋值为 1(真)。 - 否则:赋值为 0(假)。
不需要任何多余的建图和搜索,这几行判断就是 Tarjan 赐予我们的隐藏福利。
这个大小关系依赖于本模板“出栈时递增编号”的约定;若换成 Kosaraju 或调整编号顺序,比较方向可能相反,不能直接照搬。
5. 课堂手推与验证
假设有变量 1 和 2。要求满足两个条件:
- 两人至少去一个 (
) - 1 必须去 (
)
映射规则:真节点为
- 条件 1 (
): ( ),并且 ( )。 - 条件 2 (
): ( )。
运行 Tarjan:
在这个图 (3->2, 4->1, 3->1) 中,没有环,每个点都是独立的 SCC。
1 和 2 没有任何出边,一定是第一批出栈的,它们的 scc 编号最小。
而 3 能走到 1 和 2,一定是后出栈的,scc[3] 会大于 scc[1]。
判定:
scc[1] < scc[3]?是的,1 先出栈。所以变量 1 取真(去)。
按下面代码从 1 到 4 的访问顺序,scc[2]=2 < scc[4]=4,所以变量 2 也取 1。验证:两人都去,满足 1 必去,也满足 1 或 2 至少去一个。
6. 完整 2-SAT 模板代码
💡 【实战程序】 2-SAT 问题求解模板。 输入说明:第一行
表示布尔变量数(编号 )和约束数。接下来 行,每行格式 i a j b,表示“变量状态为 ,或者 变量 状态为 ”( )。 输出说明:有解输出 POSSIBLE并给出个变量的 0/1 赋值;无解输出 IMPOSSIBLE。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 200005; // 注意:有真假两个状态,节点数开到原变量数的两倍
vector<int> node[N];
int dfn[N], low[N], instk[N], scc[N];
int id = 0, sc = 0;
stack<int> stk;
// Tarjan 核心完全没变
void dfs(int u) {
dfn[u] = low[u] = ++id;
stk.push(u);
instk[u] = 1;
for (int v : node[u]) {
if (!dfn[v]) {
dfs(v);
low[u] = min(low[u], low[v]);
} else if (instk[v]) {
low[u] = min(low[u], dfn[v]);
}
}
if (dfn[u] == low[u]) {
sc++;
int x;
do {
x = stk.top();
stk.pop();
instk[x] = 0;
scc[x] = sc;
} while (x != u);
}
}
void solve() {
int n, m;
if (!(cin >> n >> m)) return;
for (int k = 1; k <= m; k++) {
int i, a, j, b;
cin >> i >> a >> j >> b;
// 节点映射:变量 x 的真状态节点标号为 x,假状态节点标号为 x + n
int real_i = a ? i : i + n;
int not_i = a ? i + n : i;
int real_j = b ? j : j + n;
int not_j = b ? j + n : j;
// 连蕴含边:如果不满足 i 的条件,就必须满足 j 的条件;反之亦然
node[not_i].push_back(real_j);
node[not_j].push_back(real_i);
}
// 图一共有 2n 个节点,跑一遍 Tarjan
for (int i = 1; i <= 2 * n; i++) {
if (!dfn[i]) dfs(i);
}
// 降维打击 1:同一个变量的真假状态在同一个 SCC 内,绝对矛盾,无解
for (int i = 1; i <= n; i++) {
if (scc[i] == scc[i + n]) {
cout << "IMPOSSIBLE\n";
return;
}
}
// 降维打击 2:利用 scc 编号反向拓扑序确定赋值
cout << "POSSIBLE\n";
for (int i = 1; i <= n; i++) {
// scc 越小,说明越早出栈,拓扑序越靠后,越安全
if (scc[i] < scc[i + n]) {
cout << 1 << (i == n ? "" : " ");
} else {
cout << 0 << (i == n ? "" : " ");
}
}
cout << '\n';
}
signed main() {
ios::sync_with_stdio(0), cin.tie(0);
solve();
return 0;
}
/*
输入样例:
2 2
1 1 2 1
1 1 1 1
输出样例:
POSSIBLE
1 1
*/
上面是本代码的实际输出。可行解不唯一,1 0 也满足这些约束;这类题通常用 SPJ 检查合法性,不要求逐字匹配某一组赋值。
六、连通性算法多维特征对比
| 比较维度 | 并查集 (DSU) | Tarjan 强连通分量 | Tarjan 割点/桥 (双连通分量) |
|---|---|---|---|
| 适用图类型 | 无向图 (维护等价连通块) | 有向图 (识别循环回路) | 无向图 (识别关键节点/边) |
| 底层数据结构 | 一维数组 (树形指针路径压缩) | 栈 + 深度优先搜索树 (DFS) | 深度优先搜索树 (DFS) |
| 核心状态变量 | fa[x] (指向代表元) |
dfn[u], low[u], instk[u] |
dfn[u], low[u] |
| 时间复杂度 | 均摊 |
严格 |
严格 |
| 典型应用场景 | 最小生成树、逻辑关系推导 | 有向图缩点、2-SAT 判定 | 网络稳定性判定、连通冗余分析 |