算法学习笔记(17): tarjan算法

tarjan算法在众多问题都有解决方法。

新概念定义

建立在一个有向图的DFS生成树中:

graph TD
1((1))
2((2))
3((3))
4((4))
5((5))
6((6))
7((7))
8((8))
9((9))
		
    1 --> 2
    2 --> 3
    3 --> 4
    3 --> 5
    5 --> 6
    3 -.-> 6
    2 --> 7
    7 -.-> 1
    1 --> 8
		8 --> 9
		9 -.-> 7

上图的搜索顺序为 1 2 3 4 5 6 7 8 9

在对于一个有向图进行DFS生成树的时候,我们使用visited来标记已经走过的节点,对于上图,实边就是我们生成树的部分,但是其中的虚线边也是我们dfs走到的,因为探到的点已经visited,所以这个边并不加入生成树。我们有以下分类:

  • 树边:搜索中探到的点并没有被访问过,这条边也被加入生成树中。
  • 返祖边:探到的点visited了,而且是父节点中的。(7 → 1)
  • 横叉边:探到的点visited了,但不是父节点中的。(9 → 7)
  • 前向边:探到的点visited了,在搜索的时候子树中的结点(3 → 6)

强联通分量

targan基于对图的DFS生成树,运用了两个特殊的数组,或者是两个特殊的属性值:dfn时间戳和low追溯值。

  • dfn时间戳:dfn[x]=节点x被第一次访问时的顺序
  • low追溯值:low[x]=从节点x出发,能访问到的最早的时间戳

算法步骤:

  • DFS过程中,如果这个点没被访问,就dfs它,并根据它的low值更新low值。
  • 如果被访问过,那么判断这个边的点是否在栈中,如果不在说明已经属于某个scc,在就根据它的dfn更新自己的low值
  • 如果遇到dfn[cur] == low[cur],则不断出栈直到自己,这段的点属于一个SCC。
void tarjan(const vector<int> *adj, int n){
    vector<int> dfn(n + 1);
    vector<int> low(n + 1);
    int dfnNo = 1;
    vector<bool> inStack(n + 1);
    stack<int> stk;
    vector<vector<int>> sccs;  // 存储所有的强连通分量

    function<void(int)> dfs = [&](int cur){
        dfn[cur] = low[cur] = dfnNo;
        dfnNo++;
        stk.push(cur);
        inStack[cur] = true;
        for(int to: adj[cur]){
            if(dfn[to] == 0){ // to 没有被访问到
                dfs(to);
                low[cur] = min(low[cur], low[to]);
            }else if(inStack[to]){
                low[cur] = min(low[cur], dfn[to]);
            }
        }
        if (dfn[cur] == low[cur]) {  // 如果当前节点是SCC的根节点
            vector<int> scc;
            int node;
            do {
                node = stk.top();
                stk.pop();
                inStack[node] = false;
                scc.push_back(node);
            } while (node != cur);
            sccs.push_back(scc);
        }
        for (int i = 1; i <= n; i++) {
            if (dfn[i] == 0) {
                dfs(i);
            }
        }
    };
}   

算法理解:

DFS生成树的作用(dfn的作用)

如果是根据一颗生成树中的子树来说,这个子树的根节点一定是dfn在子树中最小的。还有一点是这个根节点可以访问到子树中的所有点。

如果一个节点A的dfn == low,说明它的子树中的节点并不会访问到它以上的点,如果访问到了,那么它的low值也会被更新得更低。

为什么能直接出栈到这个节点为止,因为 既然能到这个节点,说明这一段里面的所有点都dfn != low, 而他们有能到达的low点,不会低于A,如果高于A但是不等于自己,说明在之前就已经被出栈了。所以一旦有一段出栈,它们的low值都是一样的。

我们可以保证的是,每次出栈的一组是一个子树,所以的所有节点都可以从根节点遍历到,又low值的定义是可以访问到的最小dfn值,也就成了SCC。

关于Low值更新

需要弄清楚的事dfn标号的顺序和low值更新的顺序,low值是在dfs之后进行的,也就是回溯的时候才能处理完的值。由于是回溯,所以上面的点能访问到下面的点,也就可以根据下面的点来更新low值。

为什么有的时候用low值更新,有的时候用dfn更新,用low值更新是因为low是由子节点能访问到更小的点决定的被储存于low,如果访问到已经instack的点,那么这个点的编号肯定是最小的,所以根据这个点的dfn更新。

割点与割边(桥)

  • 割点:删除了一个点后,联通块多了一块,这个点就是割点
  • 割边:删除了一条边后,联通块多了一块,这个边就是割边

寻找割点

同样使用了tarjan算法,维护low和dfn两个属性。

low代表不经过其父亲能到达的最小的时间戳。

算法步骤:

  • 开始 DFS,我们判断某个点是否是割点的根据是:
  • 对于某个顶点u,如果存在至少一个顶点 v(u 的儿子),使得 low[v] ≥ dfn[u],即不能回到祖先,那么 u点为割点。
  • 特殊考虑起始点:如果起点在搜索树内有两个及以上的儿子,那么起点一定是割点
#include <bits/stdc++.h>
using namespace std;
int n, m;
int dfn[100001], low[100001], inde, res;
bool vis[100001], flag[100001];
vector<int> edge[100001];

void Tarjan(int cur, int par)
{
    vis[cur] = true;               // 标记
    low[cur] = dfn[cur] = ++inde;  // 打上时间戳
    int child = 0;                 // 生成树中的子节点数量
    for (auto v : edge[cur]){
        if (!vis[v])
        {
            child++;         
            Tarjan(v, cur);
            low[cur] = min(low[cur], low[v]);
            if (par != cur && low[v] >= dfn[cur] && !flag[cur])
            {   // 记录cur
                flag[cur] = true;
                res++;
            }
        }
        else if (v != par){
            low[cur] = min(low[cur], dfn[v]);
        }
    }
    // 根节点需要 2 个儿子才可以, 用par == cur判断根节点
    if (par == cur && child >= 2 && !flag[cur])
    {  // 记录cur
        flag[cur] = true;
        res++; 
    }
}

算法思路和细节

low[v] ≥ dfn[u]为什么就是割点,此时是从父节点到了这个点,因为这里的low忽略了连接父节点的边,这句话说明了v不通过这条父节点的边就连接不到u,又因为u也有父节点,所以在没有u的情况下,v肯定无法连接到u的父节点,证明了u是割点。

为什么根节点考虑不一样?

就如前面说的,证明利用到了u的父节点,作为根节点并没有父节点,因此利用了最小生成树,从根节点出去的第一个子树A包含了所有从ROOT - A的点,因此其他没走过的点就必须要从ROOT的其他边通过,所以删掉ROOT,这些点就走不到了,ROOT因此成为割点。

寻找割边

实际上刚刚的点和寻找割边思路十分相似。

我们的之前的重点放在不能走到u的父节点上,使用low[v] >= dfn[cur],实际上就是cur往下发出的这条边,此时改为low[v] > dfn[cur],如果不能连接到cur,就代表一旦断掉cur往下的这条边,就会不连通,所以割点和割边的寻找方法类似。

int low[MAXN], dfn[MAXN], dfs_clock;
bool isbridge[MAXN];
vector<int> G[MAXN];
int cnt_bridge;
int father[MAXN];

void tarjan(int u, int fa) {
  father[u] = fa;
  low[u] = dfn[u] = ++dfs_clock;
  for (int i = 0; i < G[u].size(); i++) {
    int v = G[u][i];
    if (!dfn[v]) {
      tarjan(v, u);
      low[u] = min(low[u], low[v]);
      if (low[v] > dfn[u]) {
        isbridge[v] = true;
        ++cnt_bridge;
      }
    } else if (dfn[v] < dfn[u] && v != fa) {
      low[u] = min(low[u], dfn[v]);
    }
  }
}

双连通分量

我们解决了有向图中的强联通分量,如果把相似问题移到无向图上:

边双连通

我们定义边双连通:

在一张连通的无向图中,对于两个点 u 和 v,无论删去哪一条边(只能删去一条)都不能使它们不连通。

例如一个无向环中的每一个点,它们都是双联通的,去掉任意的一条,它们还是在一条链上联通,所以它们是边双连通。

解法一

删去所有的割边,剩下的每个连同块就是一个边连通分量。

用dfs遍历每个连通块记录即可。

#include <algorithm>
#include <cstdio>
#include <vector>

using namespace std;
const int N = 5e5 + 5, M = 2e6 + 5;
int n, m, ans;
int tot = 1, hd[N];

struct edge
{
    int to, nt;
} e[M << 1];

void add(int u, int v) { e[++tot].to = v, e[tot].nt = hd[u], hd[u] = tot; }

void uadd(int u, int v) { add(u, v), add(v, u); }

bool bz[M << 1];
int bcc_cnt, dfn[N], low[N], vis_bcc[N];
vector<vector<int>> bcc;

void tarjan(int x, int in)
{
    dfn[x] = low[x] = ++bcc_cnt;
    for (int i = hd[x]; i; i = e[i].nt)
    {
        int v = e[i].to;
        if (dfn[v] == 0)
        {
            tarjan(v, i);
            if (dfn[x] < low[v])
                bz[i] = bz[i ^ 1] = 1;
            low[x] = min(low[x], low[v]);
        }
        else if (i != (in ^ 1))
            low[x] = min(low[x], dfn[v]);
    }
}

void dfs(int x, int id)
{
    vis_bcc[x] = id, bcc[id - 1].push_back(x);
    for (int i = hd[x]; i; i = e[i].nt)
    {
        int v = e[i].to;
        if (vis_bcc[v] || bz[i])
            continue;
        dfs(v, id);
    }
}

int main()
{
    scanf("%d%d", &n, &m);
    int u, v;
    for (int i = 1; i <= m; i++)
    {
        scanf("%d%d", &u, &v);
        if (u == v)
            continue;
        uadd(u, v);
    }
    for (int i = 1; i <= n; i++)
        if (dfn[i] == 0)
            tarjan(i, 0);
    for (int i = 1; i <= n; i++)
        if (vis_bcc[i] == 0)
        {
            bcc.push_back(vector<int>());
            dfs(i, ++ans);
        }
    printf("%d\\n", ans);
    for (int i = 0; i < ans; i++)
    {
        printf("%llu", bcc[i].size());
        for (int j = 0; j < bcc[i].size(); j++)
            printf(" %d", bcc[i][j]);
        printf("\\n");
    }
    return 0;
}

解法二

寻找边双连通和强联通分量的相似之处,对于强联通分量,就是在寻找一个环,而边联通分量和环是一样的,在一个环内的所有点必定是边双连通的。

或者说,在 DFS 生成树上的一个强连通分量,在原无向图中是边双连通分量。

所以求边双连通分量的过程实际上就是求强连通分量的过程。

复杂度O(M + N)

#include<bits/stdc++.h>
using namespace std;

const int N = 5e5 + 5;
int n, m;

vector<vector<int>> adj(N); // 邻接表表示图
vector<int> dfn(N), low(N), inStack(N);
int bcc_cnt = 0; 
vector<vector<int>> bcc; // 存储双连通分量
stack<int> st;

void add_edge(int u, int v) {
    adj[u].push_back(v);
    adj[v].push_back(u); // 无向图加两条边
}

void tarjan(int u, int parent) {
    low[u] = dfn[u] = ++bcc_cnt;
    st.push(u);
    inStack[u] = 1;
    int p = 0; // 让重边只判断一次
    for (int v : adj[u]) {
        if (v == parent and p == 0){
            p++; // 让重边只判断一次
            continue; // 忽略返回父节点的边
        } 
        if (!dfn[v]) { 
            tarjan(v, u);
            low[u] = min(low[u], low[v]);
        } else if (inStack[v]) {
            low[u] = min(low[u], dfn[v]);
        }
    }

    if (dfn[u] == low[u]) { // 找到一个双连通分量
        vector<int> component;
        component.push_back(u);
        while(st.top() != u){
            component.push_back(st.top());
            inStack[st.top()] = 0;
            st.pop();
        }
        st.pop();
        bcc.push_back(component);
    }
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        int u, v;
        cin >> u >> v;
        if (u != v) add_edge(u, v); // 忽略自环
    }

    for (int i = 1; i <= n; ++i) {
        if (!dfn[i]) tarjan(i, 0);
    }

    cout << bcc.size() << "\\n";
    for (const auto& component : bcc) {
        cout << component.size();
        for (int v : component) {
            cout << " " << v ;
        }
        cout << "\\n";
    }
    return 0;
}

点双连通

在一张连通的无向图中,对于两个点 u 和 v,如果无论删去哪个点(只能删去一个,且不能删 u 和v自己)都不能使它们不连通,我们就说 v 和 u 点双连通。

解法一

回忆找割点算法 ,通过找割点的判断方式找到割点,再和边双连通分量一样的原理,和寻找强联通类似,将点从栈中取出存入一个点双连通分量。

#include <cstdio>
#include <vector>
#include <algorithm>

using namespace std;

const int N = 5e5 + 5; // 最大节点数

int n, m;
vector<int> adj[N]; // 邻接表表示图
vector<int> dfn(N), low(N), sta(N), bcc[N];
int timestamp = 0, bcc_count = 0, top = 0;
vector<bool> is_cut(N);
int root;

void add_edge(int u, int v) {
    adj[u].push_back(v);
    adj[v].push_back(u); // 无向图,两条边
}

void tarjan(int u) {
    dfn[u] = low[u] = ++timestamp;
    sta[++top] = u;

    if (u == root && adj[u].empty()) {
        bcc[++bcc_count].push_back(u);
        return;
    }

    int child_count = 0;
    for (int v : adj[u]) {
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);

            if (low[v] >= dfn[u]) {
                if (++child_count > 1 || u != root) is_cut[u] = true;
                bcc_count++;
                do {
                    bcc[bcc_count].push_back(sta[top--]);
                } while (sta[top + 1] != v);
                bcc[bcc_count].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) {
        int u, v;
        scanf("%d%d", &u, &v);
        if (u != v) add_edge(u, v);
    }

    for (int i = 1; i <= n; ++i) {
        if (!dfn[i]) {
            root = i;
            tarjan(i);
        }
    }

    printf("%d\\n", bcc_count);
    for (int i = 1; i <= bcc_count; ++i) {
        printf("%lu ", bcc[i].size());
        for (int v : bcc[i]) {
            printf("%d ", v);
        }
        printf("\\n");
    }

    return 0;
}

LCA最近公共祖先

Tarjan 算法是一个离线算法,即不能动态的修改输入的图。

思路是先输入图,再把所有的查询作为虚拟边建立查询图。

根据回溯的思想来看,我们找到的点对LCA是不断往上的。

了解一个特性:如果我搜完了一个子树,那么之后的点的lCA就和这个子树完全无关了。

那什么时候和子树有关,就是两个点都在其最小子树内,此时这个子树的根就是它们的LCA。

这种标记形式的LCA什么时候变化?

当我检查完一颗子树,已经处理掉了子树内的所有情况,此时往上回溯到了比它更大的子树时,这些已经访问过的节点可能的LCA就变成这个更大子树的根了。

算法步骤(DFS内):

  • dfs步骤
  • 将当前点标记为LCA标准点(和并查集原理相同)
  • 检查查询边,如果要查询的边已经访问过(有lCA标准点)他俩的LCA就是这个点
  • 如果没有访问过,就不处理
#include <algorithm>
#include <cstring>
#include <iostream>
using namespace std;

class Edge
{
public:
    int toVertex, fromVertex;
    int next;
    int LCA;
    Edge() : toVertex(-1), fromVertex(-1), next(-1), LCA(-1) {};
    Edge(int u, int v, int n) : fromVertex(u), toVertex(v), next(n), LCA(-1) {};
};

const int MAX = 100;
int head[MAX], queryHead[MAX];
Edge edge[MAX], queryEdge[MAX];
int parent[MAX], visited[MAX];
int vertexCount, queryCount;

int find(int x)
{
    if (parent[x] == x){
        return x;
    }
    else{
        return parent[x] = find(parent[x]);
    }
}

void tarjan(int u)
{
    parent[u] = u;
    visited[u] = 1;

    for (int i = head[u]; i != -1; i = edge[i].next)
    {
        Edge &e = edge[i];
        if (!visited[e.toVertex])
        {
            tarjan(e.toVertex);
            parent[e.toVertex] = u;
        }
    }

    for (int i = queryHead[u]; i != -1; i = queryEdge[i].next)
    {
        Edge &e = queryEdge[i];
        if (visited[e.toVertex])
        {
            queryEdge[i ^ 1].LCA = e.LCA = find(e.toVertex);
        }
    }
}

int main()
{
    memset(head, 0xff, sizeof(head));
    memset(queryHead, 0xff, sizeof(queryHead));

    cin >> vertexCount >> queryCount;
    int count = 0;
    for (int i = 0; i < vertexCount - 1; i++)
    {
        int start = 0, end = 0;
        cin >> start >> end;

        edge[count] = Edge(start, end, head[start]);
        head[start] = count;
        count++;

        edge[count] = Edge(end, start, head[end]);
        head[end] = count;
        count++;
    }

    count = 0;
    for (int i = 0; i < queryCount; i++)
    {
        int start = 0, end = 0;
        cin >> start >> end;

        queryEdge[count] = Edge(start, end, queryHead[start]);
        queryHead[start] = count;
        count++;

        queryEdge[count] = Edge(end, start, queryHead[end]);
        queryHead[end] = count;
        count++;
    }

    tarjan(1);

    for (int i = 0; i < queryCount; i++)
    {
        Edge &e = queryEdge[i * 2];
        cout << "(" << e.fromVertex << "," << e.toVertex << ") " << e.LCA << endl;
    }

    return 0;
}

每次检查当前的LCA标记也就是子树中parent[x] == x 的x,以这种类似于并查集的思想,可以让后面的点一起批量转换标记。

tarjan的思路在于一个标记点,类似于之前的low一样,额外的辅助标记带来的独特作用。