详解Tarjan之割点【C++】【P3388 题解】

例题

P3388 割点

首先认识两个数组:

int dfn[N];
int low[N];

dfn[i] = 节点i的时间戳,就是第几个访问到该节点(子节点之间时间戳不同不影响结果)。

low[i] = 从节点i出发,不经过父节点能够回到的最早时间戳

如下图所示(动图包含递归计算dfn和low的过程):

如果你觉得太快了,请跳转

low怎么计算?

// v为u的孩子节点
1. 若 v 是未访问子节点:low[u] = min(low[u], low[v])
// 孩子能到哪 我就能到哪
2. 若 v 是已访问节点且非父节点:low[u] = min(low[u], dfn[v])

相信你已经明白dfn和low了。

判断是否为割点:

情况一:非根节点

如果子节点v无法通过其他路径绕过u访问u的祖先节点【即时间戳更小的节点】,说明u必不可少,是割点。

如下图所示(u是割点):

if (low[v] >= dfn[u] && fa != -1) {
    isCut[u] = true;
}

情况二:根节点

如果根节点的子节点中【子连通块】数量(即移除根节点后剩下的连通块数量)大于等于2,它就是割点。

如下图所示(u是割点):

(u不是割点):

int child = 0;
for (int v : e[u]) {
    if (dfn[v] == 0) {
        child++;
        Tarjan(v, u);
        // ... 
    }
}
if (fa == -1 && child >= 2)
    isCut[u] = true;

完整代码

#include <iostream>
#include <cstring>
#include <vector>
using namespace std;

const int N = 2e4 + 5;

int n, m, dfn[N], low[N], t = 0;
bool isCut[N];
vector<int> e[N];

void Tarjan(int u, int fa) {
    dfn[u] = low[u] = ++t;
    int child = 0;

    for (int v : e[u]) {
        if (dfn[v] == 0) {
            child++;
            Tarjan(v, u);
            low[u] = min(low[u], low[v]);

            // 非根节点且满足割点条件
            if (low[v] >= dfn[u] && fa != -1) {
                isCut[u] = true;
            }
        }
        else if (v != fa) {
            low[u] = min(low[u], dfn[v]);
        }
    }
    if (fa == -1 && child >= 2)
        isCut[u] = true;
}


int main() {
    cin >> n >> m;
    while (m--) {
        int x, y;
        cin >> x >> y;
        e[x].push_back(y);
        e[y].push_back(x);
    }
    for (int i = 1; i <= n; i++) {
        if (dfn[i] == 0)
            Tarjan(i, -1);
    }
            
    int cnt = 0;
    for (int i = 1; i <= n; i++) 
        if (isCut[i]) cnt++;
    cout << cnt << endl;
    bool f = false;
    for (int i = 1; i <= n; i++) {
        if (isCut[i] == true) {
            if (f) cout << " ";
            cout << i;
            f = true;
        }
    }
    return 0;
}

<think>好的,用户想了解LCA(最近公共祖先)的Tarjan算法详解以及C++代码模板。首先,我需要回顾Tarjan算法的原理,确保自己理解正确。Tarjan算法是离线算法,需要先读取所有查询,然后通过DFS和并查集来高效处理LCA问题。时间复杂度大约是O(n + α(n)Q),其中α是阿克曼函数的反函数,效率很高。 接下来,用户可能需要知道Tarjan算法的步骤。需要解释清楚DFS过程中如何标记节状态(未访问、访问中、已访问),以及如何利用并查集将已访问的子树合并到当前节的父节下。当遍历完某个节的子树后,处理与该节相关的查询,查看另一节是否已被访问,如果是,则通过并查集找到其当前的代表元素,即为LCA。 然后要考虑如何将这个过程转化为C++代码。代码结构大致包括:建图(邻接表或链表),存储查询的方式(每个节对应的查询列表),并查集的父数组和访问数组。在DFS过程中,遍历子节后,将子节的并查集父节指向当前节。处理查询时,如果对方节已访问,则记录LCA结果。 需要注意的细节包括:查询需要双向存储,比如对于查询(u, v),需要同时在u的列表里添加v,和在v的列表里添加u,并记录查询的索引以便存储结果。并查集的路径压缩是否必要?Tarjan算法中的并查集结构其实不需要严格的路径压缩,因为每个节在回溯时会被正确合并,但实现时可能仍然使用路径压缩来简化代码,或者按照原始Tarjan的方法处理。 可能用户会疑惑为什么在处理查询的时候,当其中一个节已经访问过,就能确定LCA。需要解释在DFS的过程中,当访问完一个子树后,子树中的节的并查集父节会被合并到当前节,而如果此时另一个查询节已经被访问过但未完全回溯,那么它们的LCA就是当前并查集找到的根。 然后,代码模板部分要清晰,包括变量定义、DFS函数、并查集查找函数。例如,使用邻接表存储树结构,查询用vector<pair<int, int>>数组存储,结果数组对应每个查询。并查集的父数组初始化为自身,访问数组标记是否已处理完毕。 在示例部分,可以给出一个简单的树结构,比如节1连接2和3,2连接4和5,然后给出查询如(4,5)的LCA是2,(4,3)的LCA是1等,通过代码运行结果展示正确性。 可能用户会遇到的常见问题包括:如何处理非二叉树?Tarjan算法适用于任何树结构,不限于二叉,所以邻接表的存储方式没有问题。另外,离线处理意味着所有查询必须预先知道,不能在线处理。需要提醒用户这一。 最后检查是否有错误或遗漏的部分。比如,确保在DFS中处理完所有子节后才处理查询,否则可能导致错误。另外,并查集合并的方向是否正确,应该是将子节合并到父节中。还有,处理查询时是否需要去重?比如多次查询同一个pair是否需要特殊处理,但代码中已经为每个查询分配了索引,所以不需要。 总结,Tarjan算法通过DFS和并查集高效解决离线LCA问题,代码实现需要注意查询的存储和处理顺序,以及并查集的正确合并方式。给出清晰的代码模板和示例可以帮助用户理解。</think>### LCA 的 Tarjan 算法详解 Tarjan 算法是一种**离线算法**,用于高效解多个节对的最近公共祖先(LCA)。其核心思想是 **DFS + 并查集**,时间复杂度接近线性(\(O(n + Q\alpha(n))\)),其中 \(Q\) 是查询次数,\(\alpha\) 是阿克曼函数的反函数。 --- #### 算法原理 1. **DFS 遍历树**:从根节开始深度优先遍历,标记节的三种状态: - **未访问**:节未被访问。 - **访问中**:节正在被访问,但其子树未完全遍历。 - **已访问**:节及其子树已完全遍历。 2. **并查集合并路径**: - 当回溯到某个节时,将其子树通过并查集合并到其父节下。 - **关键**:已访问的子树被合并到父节的集合中,而正在访问的子树保持独立。 3. **处理查询**: - 对每个查询 \((u, v)\),如果其中一个节(如 \(v\))已被访问,则 LCA 为 \(v\) 所在集合的根。 - 查询结果在回溯过程中动态确定。 --- #### C++ 代码模板 ```cpp #include <vector> using namespace std; struct Query { int v, id; }; // 查询结构:v是查询的另一个节,id是查询编号 vector<vector<int>> adj; // 树的邻接表 vector<vector<Query>> queries; // 每个节的查询列表 vector<int> parent; // 并查集父节 vector<bool> visited; // 标记节是否已访问 vector<int> ans; // 存储每个查询的答案 int find(int u) { if (parent[u] != u) { parent[u] = find(parent[u]); // 路径压缩 } return parent[u]; } void tarjan(int u) { visited[u] = true; // 标记为正在访问 // 遍历子节 for (int v : adj[u]) { if (!visited[v]) { tarjan(v); parent[v] = u; // 合并子节到当前节 } } // 处理所有与u相关的查询 for (Query q : queries[u]) { if (visited[q.v]) { // 如果另一个节已访问 ans[q.id] = find(q.v); // LCA为当前集合的根 } } } // 主函数调用示例 int main() { int n = 5; // 节数(假设树节编号为1~5) adj.resize(n + 1); queries.resize(n + 1); parent.resize(n + 1); visited.resize(n + 1, false); ans.resize(m); // m为查询次数 // 示例:树结构 (1-2, 1-3, 2-4, 2-5) adj[1].push_back(2); adj[2].push_back(1); adj[1].push_back(3); adj[3].push_back(1); adj[2].push_back(4); adj[4].push_back(2); adj[2].push_back(5); adj[5].push_back(2); // 示例查询:假设查询(4,5)的LCA,id=0 queries[4].push_back({5, 0}); queries[5].push_back({4, 0}); // 初始化并查集 for (int i = 1; i <= n; i++) parent[i] = i; tarjan(1); // 从根节开始遍历 // 输出结果:ans[0]应为2 return 0; } ``` --- #### 关键解释 1. **离线处理**:需预先存储所有查询,无法实时处理动态查询。 2. **并查集合并方向**:子节回溯时合并到父节,保证集合代表元为当前最高层节。 3. **路径压缩**:在 `find` 函数中优化查询效率,确保接近常数时间。 --- #### 示例说明 - **树结构**:根为1,子节为2和3;2的子节为4和5。 - **查询 (4,5)**:LCA为2。 - **查询 (4,3)**:LCA为1。 通过 Tarjan 算法,DFS 遍历过程中动态合并并查集,并在回溯时处理查询,确保高效解所有LCA。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值