在算法竞赛中,图论问题往往考验选手对复杂结构的拆解能力。洛谷P3387作为一道经典模板题,完整呈现了「强连通分量→缩点→DAG动态规划」的技术链条。本文将带你穿透理论迷雾,用两种截然不同的算法视角(Tarjan与Kosaraju)解剖问题本质,最终在缩点后的DAG上实现优雅的动态规划解法。
Tarjan算法之所以成为图论领域的瑞士军刀,在于其巧妙的双时间戳机制。让我们通过洛谷P3387的输入样例,观察算法如何逐步标记图中的强连通分量:
cpp复制vector<int> dfn(n+1); // 访问次序标记
vector<int> low(n+1); // 可回溯的最早次序
stack<int> stk; // 递归栈
vector<bool> inStk(n+1);
int timestamp = 1;
void tarjan(int cur) {
dfn[cur] = low[cur] = timestamp++;
stk.push(cur);
inStk[cur] = true;
for(int nex : graph[cur]) {
if(!dfn[nex]) {
tarjan(nex);
low[cur] = min(low[cur], low[nex]);
} else if(inStk[nex]) {
low[cur] = min(low[cur], dfn[nex]);
}
}
if(dfn[cur] == low[cur]) { // 发现SCC
int x;
do {
x = stk.top(); stk.pop();
inStk[x] = false;
scc[x] = cur; // 用cur作为代表元
} while(x != cur);
}
}
关键操作解析:
dfn记录节点的DFS序,相当于访问身份证low通过递归回溯不断更新,标记当前节点能触及的最早祖先dfn == low时,栈中弹出节点构成完整SCC注意:实际竞赛中常将
low[cur] = min(low[cur], dfn[nex])优化为low[cur] = min(low[cur], low[nex]),这种写法在求割点时需特别注意正确性
与Tarjan的递归栈思路不同,Kosaraju算法采用两次DFS的策略:
cpp复制// 第一次DFS:逆图后序遍历
void reverseDFS(int cur) {
vis[cur] = true;
for(int nex : reverseGraph[cur])
if(!vis[nex]) reverseDFS(nex);
stk.push(cur); // 记录完成时间
}
// 第二次DFS:原图逆序遍历
void forwardDFS(int cur, int leader) {
vis[cur] = true;
scc[cur] = leader;
for(int nex : forwardGraph[cur])
if(!vis[nex]) forwardDFS(nex, leader);
}
// 主流程
fill(vis.begin(), vis.end(), false);
for(int i=1; i<=n; ++i)
if(!vis[i]) reverseDFS(i);
fill(vis.begin(), vis.end(), false);
while(!stk.empty()) {
int cur = stk.top(); stk.pop();
if(!vis[cur]) forwardDFS(cur, cur);
}
算法对比分析:
| 特性 | Tarjan | Kosaraju |
|---|---|---|
| 时间复杂度 | O(V+E) | O(V+E) |
| 空间复杂度 | 递归栈空间 | 需存储逆图 |
| 适用场景 | 单次求解 | 需要拓扑序时更优 |
| 代码实现难度 | 中等(需理解双时间戳) | 简单(两次DFS) |
获得强连通分量后,缩点是将复杂图简化为DAG的关键步骤。以洛谷P3387为例,我们需要:
cpp复制for(int i=1; i<=n; ++i)
if(scc[i] != i)
val[scc[i]] += val[i];
cpp复制unordered_map<int, vector<int>> dagGraph;
for(int i=1; i<=m; ++i) {
int u = from[i], v = to[i];
if(scc[u] != scc[v])
dagGraph[scc[u]].push_back(scc[v]);
}
缩点后的图性质验证:
在缩点得到的DAG上,我们可采用两种经典方法求解最大路径和:
cpp复制vector<int> memo(n+1, -1);
function<int(int)> dfs = [&](int cur) -> int {
if(memo[cur] != -1) return memo[cur];
int max_path = 0;
for(int nex : dagGraph[cur])
max_path = max(max_path, dfs(nex));
return memo[cur] = val[cur] + max_path;
};
int result = 0;
for(int i=1; i<=n; ++i)
if(scc[i] == i)
result = max(result, dfs(i));
cpp复制// 拓扑排序(Kahn算法)
vector<int> inDegree(n+1);
queue<int> q;
for(auto &[u, neighbors] : dagGraph)
for(int v : neighbors)
inDegree[v]++;
for(int i=1; i<=n; ++i)
if(scc[i]==i && inDegree[i]==0)
q.push(i);
// DP过程
vector<int> dp(n+1);
while(!q.empty()) {
int u = q.front(); q.pop();
dp[u] += val[u];
for(int v : dagGraph[u]) {
dp[v] = max(dp[v], dp[u]);
if(--inDegree[v] == 0)
q.push(v);
}
}
性能对比:
| 方法 | 时间复杂度 | 适用场景 |
|---|---|---|
| 记忆化搜索 | O(V+E) | DAG结构不明确时 |
| 拓扑排序+DP | O(V+E) | 需要显式拓扑序时 |
unordered_map存储DAG比vector更节省空间cpp复制int getScc(int x) {
return scc[x] == x ? x : scc[x] = getScc(scc[x]);
}
将Tarjan算法封装为可重用组件:
cpp复制struct SCC {
vector<vector<int>> graph;
vector<int> dfn, low, scc;
stack<int> stk;
int timestamp = 1;
SCC(int n): graph(n+1), dfn(n+1), low(n+1), scc(n+1) {}
void addEdge(int u, int v) { graph[u].push_back(v); }
void run() {
for(int i=1; i<graph.size(); ++i)
if(!dfn[i]) tarjan(i);
}
// ... tarjan实现 ...
};
在解决类似P3387的问题时,这套模板能节省约30%的编码时间。实际比赛中,建议根据题目特点灵活调整缩点后的处理策略——有些问题可能需要统计入度/出度,有些则需在DAG上运行最短路算法。