『学习笔记』Tarjan

概念

时间戳(dfn)

说白了就是用dfs遍历图中所有节点的顺序,很好理解,一般用数组 \(dfn[\ i\ ]\) 表示第几个访问的节点 \(i\)

追溯值(low)

看起来比较抽象,其实我也不太好给出合理的解释,大概意思就是 \(low[\ i \ ]\) 代表节点 \(i\) 通过直接或间接存在的路径,可以返回到的时间戳(dfn值)最小的节点 \(j\) ,其中 \(low[\ i \ ] = dfn[\ j \ ]\) 。然后就引入了一些有关搜索树中边的名称的概念,在下面的板块会提及。

搜索树

这个也很好理解,就是在一张连通图中(不管有向还是无向),所有的节点以及发生递归的边共同构成一张搜索树,显然搜索树中节点数比边数多1(这是树的性质)。
相应的,如果这张图不是连通图,那么相应构成的就是搜索森林。

在搜索树中,我们以有向边 \((x, y)\) 为例(先后顺序不代表前父后子):

  • 若边 \((x, y)\) 是搜索树中的边,则称边 \((x, y)\)树枝边

  • 若边 \((x, y)\) 不是搜索树中的边,且在搜索树中 \(y\)\(x\) 的后代,则边 \((x, y)\)前向边(祖先指向后代)。

  • 若边 \((x, y)\) 不是搜索树中的边,且在搜索树中 \(x\)\(y\) 的后代,则边 \((x, y)\)后向边(后代指向祖先)。

  • 整个图中除了以上三种边就是横叉边

PS:显然,只有有向图中存在横叉边,证明略。

Tarjan与有向图

强连通

有向图中,若存在一个子图,且该子图中的任意两点都相互连通,则该子图为强连通图

根据定义,显然,一个环一定是强连通图;但是,一个强连通图不一定是一个环,有可能是多个环拼接在一起。

在有向图中最大的强连通图为强连通分量。

PS:有些书和博客把这里的最大强连通子图说成极大强连通子图,都是一个意思。

强连通分量判定法则(scc)

根据定义,亦可得,每一个节点只会属于一个强连通子图。

其实真正求的是哪一个节点属于哪一个强连通图,强连通分量的定义是最大的强连通子图,强连通分量一般来说是一个(有可能是多个相等的),但是好像也可以指强连通图。不知怎么搞的,既然都这么说,那就这么说罢。

我们按照dfn的顺序将每一个节点编号入栈,当 \(dfn_x=low_x\) 时,将栈顶到 \(x\) 之间所有的节点出栈,这些节点构成一个强连通分量。

\(dfn_x=low_x\) 即为强连通分量的判定条件。简要说明:节点 \(x\) 能返回到最早的节点就是 \(x\) 本身。从 \(x\)\(s.top(\ )\) 这些节点的父节点可以看做 \(x\) ,且它们最多只能返回到节点 \(x\) ,所以这些节点构成一个强连通分量。

代码实现如下:

void tarjan(int x) {
    dfn[x] = low[x] = ++cnt;
    sta[++top] = x; ins[x] = true; // ins[x] 标记 x 是否入栈

    for (int i = head[x]; ~i; i = nxt[i]) {
        int y = ver[i];

        if (!dfn[y]) {
            tarjan(y);
            low[x] = min(low[x], low[y]);
        } else if (ins[y]) {
            low[x] = min(low[x], dfn[y]);
        }
    }

    if (dfn[x] == low[x]) {
        int y; ++num; // num 是连通块的编号

        do {
            y = sta[top--]; ins[y] = false;
            c[y] = num; // c[y] 保存 y 属于哪个连通块
            scc[num].push_back(y); // scc[num] 保存连通块 num 里有哪些点
        } while (y != x);
    }
}

scc缩点

强连通分量的缩点很简单。因为每个节点只可能属于一个 scc,所以我们可以把一个 scc 缩成一个点。设存在有向边 \((x,y)\) ,若 \(c_x = c_y\) ,则这条边会被吞掉;反之,这条边就会被建立。

代码实现如下:

// 以下代码加到主函数中:
for (int x = 1; x <= n; ++x) {
    for (int i = head[x]; i != -1; i = nxt[i]) {
        int y = ver[i];
        if (c[x] == c[y]) continue;
        add_c(c[x], c[y]);
    }
}

Tarjan与无向图

桥(割边)

定义很好理解:在无向连通图中,若删去一条边,使得该连通图分裂成两个不连通的连通图,则这条边为桥(割边)

桥(割边)判定法则

\[dfn_x<low_y \]

其中 \(y\)\(x\) 的子节点。
简要说明:\(y\) 出发,没有任何一条路径能到达 \(x\)\(x\) 以上的节点,显然这条边被删去以后分裂成两个子连通图,即这条边是桥(割边)。

代码实现如下:

void tarjan(int x, int fa_edge) { // 第二个参数传的是刚才发生递归的边的编号
    dfn[x] = low[x] = ++cnt;

    for (int i = head[x]; ~i; i = nxt[i]) {
        int y = ver[i];

        if (!dfn[y]) {
            tarjan(y, i);
            low[x] = min(low[x], low[y]);

            if (dfn[x] < low[y]) {
                bridge[i] = bridge[i ^ 1] = true;
                // bridge[i] 保存 i 是否为桥(割边)
            }

        } else if (i != (fa_edge ^ 1)) { // 成对变换
            low[x] = min(low[x], dfn[y]);
        }
    }
}

这里用到了一个叫 成对变换 的东西,若不懂则可以去借鉴其他文章。这里默认读者理解什么是成对变换。

边双连通分量(e-dcc)

图的双连通分量(dcc)包括边双连通分量(e-dcc)点双连通分量(v-dcc),这里先介绍 e-dcc。

在无向连通图中,不包含桥的子图 为边双连通图,最大 的边双连通子图为边双连通分量。
(定义与强连通分量类似。)
说白了就是把一张无向图中所有的桥删去,每一个连通块就是一个 e-dcc。

代码实现如下:

// 各数组名的含义与 scc 上面的类似
void edcc_dfs(int x) {
    c[x] = num;

    for (int i = head[x]; i != -1; i = nxt[i]) {
        int y = ver[i];

        if (bridge[i] || c[y]) continue;
        edcc_dfs(y);
    }
}

// 以下代码加到主函数中:
for (int i = 1; i <= n; ++i) {
    if (c[i]) continue;
    ++num;
    edcc_dfs(i);
}

e-dcc缩点

和 scc 的缩点的思路类似。

代码如下:

// 以下代码加到主函数中:
for (int i = 2; i <= tot; ++i) {
    // tot 是链式前向星中边的编号,为了保证正常的成对变换,tot 要从偶数编号开始计起
    int x = ver[i], y = ver[i ^ 1];

    if (c[x] == c[y]) continue;
    add_c(c[x], c[y]);
}

割点(割顶)

割点(割顶)判定法则

\[dfn_x\le low_y \]

其中 \(y\)\(x\) 的子节点。
解释与桥的类似。简要说明:\(y\) 出发,无论走哪一条路径都只能到达 \(x\)\(x\) 以下的节点,显然这个点被删去以后分裂成两个子连通图,即这条点是割点(割顶)。

值得注意的是,若 \(x\) 为搜索树的根节点,则 \(x\) 仍需要满足 至少有两个节点与其相连 才可能保证 \(x\) 为割点。所以我们要对根节点进行特判。

代码实现如下:

void tarjan(int x) {
    dfn[x] = low[x] = ++cnt;
    int flag(0); // 记录有多少个点与 x 连通

    for (int i = head[x]; ~i; i = nxt[i]) {
        int y = ver[i];

        if (!dfn[y]) {
            tarjan(y);
            low[x] = min(low[x], low[y]);

            if (dfn[x] <= low[y]) {
                ++flag;
                if (x != rt || flag > 1) cut[x] = true;
                // 若 x 不为根则 x 一定是割点
                // 若 x 为根,且和根相连的节点数 >= 2 亦为真
                // 否则不是割点
            }

        } else {
            low[x] = min(low[x], dfn[y]);
        }
    }
}

点双连通分量(v-dcc)

在无向连通图中,不包含桥的子图 为边双连通图,最大 的边双连通子图为边双连通分量。
(定义与边双连通分量类似。)

但和 e-dcc 不同的是,e-dcc 把桥删去即可求得每个边双联通分量。v-dcc 中,每一个割点可能属于多个点双连通分量。所以 v-dcc 缩点是把每个割点单拎出来,再把每个 v-dcc 单拎出来,如果这个 v-dcc 中包含割点,那么就将这个 v-dcc 和这个割点连边。

显然,如果一个点是孤立点(就是没有任何边点与其相连),那么它自己属于一个 v-dcc。所以也需要对孤立点进行特判。

代码实现如下:

void tarjan(int x) {
    dfn[x] = low[x] = ++cnt;
    sta[++top] = x;
    int flag(0);

    if (x == rt && head[x] == -1) { // 对孤立点的特判
        vdcc[++num].push_back(x);
        return;
    }

    for (int i = 0; i < ver[x].size(); ++i) {
        int y = ver[x][i];

        if (!dfn[y]) {
            tarjan(y);
            low[x] = min(low[x], low[y]);

            if (dfn[x] <= low[y]) {
                ++flag;
                if (x != rt || flag > 1) cut[x] = true;

                int z; ++num;
                do {
                    z = sta[top--];
                    vdcc[num].push_back(z);
                } while (z != y);
                vdcc[num].push_back(x); // x 可能属于多个 v-dcc
            }

        } else {
            low[x] = min(low[x], dfn[y]);
        }
    }
}

v-dcc缩点

刚才在上面的板块说了一些。就是把每个 v-dcc 缩成一个点。因为割点可能属于多个 v-dcc,所以要把每一个割点单拎出来。具体思路要结合上面的看。

代码实现如下:

// 主函数里
cnt = num;
for (int i = 1; i <= n; ++i) {
    if (cut[i]) new_cut[i] = ++cnt;
}

for (int x = 1; x <= num; ++x) {
    for (int i = 0; i < vdcc[i].size(); ++i) {
        int y = vdcc[x][i];

        if (cut[y]) {
            add_c(x, new_cut[y]);
            add_c(new_cut[y], x);
        } else c[y] = x;
    }
}
posted @ 2023-04-09 14:48  Voah  阅读(171)  评论(2编辑  收藏  举报