zoukankan      html  css  js  c++  java
  • 强连通分量(Strongly Connected Components)学习笔记

    强连通分量(Strongly Connected Components)

    时间:2019.8.2 - 2019.8.4

    咕咕咕……作者真是咕呢。。。

    定义

    一个有向图 (G) 中,若两个点 (u)(v) 能够互相到达,那么称这两个点强连通

    (G) 的一个子图 (G')中所有点互相强连通,那么称 (G') 为一个强连通子图,或称强连通分量。

    一个有向图可能会有多个强连通分量。将图中的每个极大强连通分量用一个新的点表示,再在这些点之间连上原图拥有的边,我们将会得到一个 DAG。极大强连通分量的意思是,这个强连通分量已经“满”了,不能再加入更多的点。得到这个 DAG 的过程被称为缩点。

    那么怎样求解强连通分量问题呢?我们下面介绍一种较为常用的 Tarjan 算法。

    有向图的 4 种边

    Tarjan 算法使用 DFS 遍历的方式将点分类。DFS 过程中通过添加未探索的新边组成的树称为搜索树。一个有向图上一般有 4 种边:

    1.png

    1. 树边:搜索树上的边称为树边。如图, 用黑色表示。
    2. 返祖边:指向这个点祖先的边称为返祖边(有的资料中称为后向边)。如图,用绿色表示。
    3. 前向边:指向子树中一个已经遍历过的点的边称为前向边。如图,用蓝色表示。
    4. 横叉边:从一个子树中的点指向另一个子树中的点的边称为横叉边。如图,用红色表示。注意在一条横叉边 (u ightarrow v) 中,(v) 总是更先被访问,因为若 (u) 先访问,那么 (v) 必然在 (u) 的子树中。

    返祖边和横叉边都指向一个比自己更先访问的节点,树边和前向边指向更晚访问的节点。

    注意,由于 DFS 的顺序是任意的,因此四种边不一定每次遍历都完全一致。但是这并不影响算法的正确运行。

    算法流程

    每个强连通分量都会一次性被确定下来。用一个栈,维护遍历中当前有可能成为新强连通分量的节点编号。下文为了方便,将还在栈中的点称为“栈点”,不在栈中的点称为“非栈点“。

    每个节点在访问时入栈,所在的强连通分量被确定时再出栈。容易发现,从栈顶开始往下数,越往下的节点 DFS 序越小。

    一个强连通分量的根定义为这个强连通分量在搜索中第一个访问到的节点。

    由于点与点之间可以互相到达,因此整个强连通分量都会在这个根的子树下面。设 (u) 是某个强连通分量的根,当访问完 (u) 准备返回时,当前栈中的节点应该是这样子的:

    2.png

    其中从栈顶到 (u) 这一部分,就是新强连通分量的节点集合(因为不在分量中的节点已经被弹出了)。我们只要将栈顶到 (u) 这一段弹出就行了。

    因此,重要的问题是,怎么判断某个节点是不是一个分量的根。

    dfn 和 low

    为每个点维护两个值 (dfn[u])(low[u])。其中分别定义如下(其中 (subtree(u)) 表示搜索树中以 (u) 为根的子树):

    • (dfn[u]) 就是节点 (u) 的 DFS 序。维护一个计数器 (cnt),DFS 访问到 (u) 时就将 (cnt) 加 1,并令 (dfn[u] = cnt)
    • (low[u]) 定义为以下数的最小值:(subtree(u)) 中的点的 (dfn) 最小值;(subtree(u)) 中的点仅通过一条有向边所能够到达的栈点(dfn) 最小值。

    (low[u]) 的定义看不懂?没关系,Tarjan 最精妙的部分就在这个 (low[u]) 的定义中。下面我将对 (low[u]) 详细讲解。

    为什么是栈点

    先逐条看 (low[u]) 的定义。

    1. 首先,你会发现 (subtree(u)) 中的点的 (dfn) 最小值,就是 (dfn[u])……[手动狗头](因此仿佛这个定义并没有什么卵用)

      好吧,此时 (low[u]) 就可以定义为:(subtree(u)) 中的点仅通过一条有向边所能够到达的栈点最小 (dfn),与 (dfn[u]) 的最小值。

      (low[u]) 其实表示从 (u) 出发,最早能够到达哪里。如果从 (u) 出发什么栈点都走不到,那么 (low[u]) 就只能是 (dfn[u]) 了。

      注意这里特意强调了一定要走到栈点中去。这又是为什么呢?

    2. 其次,除了 (dfn[u]) 之外,仅通过一条有向边所能够到达的栈点的最小 (dfn),又是什么意思呢?

      3.png

      不妨设能够到达某个点 (w),且 (w) 是从 (v) 通过一条有向边 (v ightarrow w) 到达的((v in subtree(u)))。先排除掉 (dfn[w] geqslant dfn[u]) 的情况,因为此时 (w) 对答案 (low[u]) 并没有贡献。

      那么剩下的就是 (dfn[w] < dfn[u]) 的情况。这种情况中,有向边 (v ightarrow w) 只能是返祖边或者横叉边

      如果 (v ightarrow w) 是返祖边,那么 (w) 就是 (u) 的祖先。此时 (w) 是栈点。如图,显然 (u)(w) 强连通。

      4.png

      (w)(u) 祖先,而 (u) 子树中的一个节点 (v) 又能够一步到达 (w),那么 (u) 不可能是它所在强连通分量的根。因为 (u)(w) 都在同一个强连通分量中,而 (w) 的深度更小,所以就算 (w) 不是根,(u) 也不可能是第一个被访问的节点,不可能是根。

      如果 (v ightarrow w) 是横叉边呢?要么 (w) 是栈点,要么不是。先看一看 (w) 不是栈点的情况:

      5.png

      这时,即使 (dfn[w] < dfn[u])(u) 能否通过 (v) 到达 (w),对 (u) 是不是根也没有影响。因为 (u) 无法从 (w) 走到它的某个祖先,更不能与 (w) 放在同一个强连通分量中。因此,(u) 仍然有可能是它所在强连通分量中第一个被访问的节点。

      另一方面,如果 (w) 是个栈点,则说明直到搜索完 (w) 返回时,直到搜索完 (w) 的父亲返回时,直到搜索完 (w) 的祖父返回时,……一直到搜索完 (u) 的某个兄弟返回时,仍然不能确定 (w) 所在强连通分量的根。(w) 所在分量的根,还得在深度更小的点中确定。

      如果 (w) 所在分量的根是 (x),那么 (x) 也将是 (u) 的祖先。(u) 可以通过 (v) 到达 (w),再通过 (w) 到达它的祖先 (x)……(u, v, w, x) 都在同一个强连通分量里面。

      6.png

      那么 (u) 肯定就不是该强连通分量第一个被访问的节点啦!(x) 显然比 (u) 更先访问。

      总而言之,只要按照规定的规则计算,(dfn[w] < dfn[u]) 都可以说明 (u) 不是它所在分量的根。

      故,若 (low[u] < dfn[u]),则 (u) 不是它所在强连通分量的根。反之,(low[u] = dfn[u]) 等价于 (u) 是强连通分量的根。此时应该将栈顶到 (u) 的这一段弹出作为新的强连通分量。

    模板代码

    由上面的分析可以容易地写出一份 C++ 代码。

    算法所需定义的变量 / 数组如下:

    int dfn[kMaxN], low[kMaxN], dfn_cnt;
    int scc[kMaxN], scc_cnt;
    stack<int> S; // 存储节点的栈
    

    其中 scc[u] 表示 (u) 所在强连通分量的编号(scc 是强连通分量的英文,Strongly connected component 的缩写),scc_cnt 表示当前强连通分量的数量,dfn_cnt 即为上文的计数器,(cnt)

    首先,我们需要维护节点的 (dfn) 值和一个栈。一开始节点的 (low) 值等于其 (dfn)

    void Tarjan(int u) {
      dfn[u] = low[u] = ++dfn_cnt;
      S.push(u);
      for...
    }
    

    然后遍历 (u) 的每条出边 (u ightarrow v),根据 (v) 的性质分类讨论。

      for (int i = 0; i < G.arcs[u].size(); i++) {
        int v = G.arcs[u][i];
        if...
      }
    

    情况 1:(v) 尚未被访问,即 (u ightarrow v) 是树边。

        if (!dfn[v]) {
          Tarjan(v);
          low[u] = min(low[u], low[v]); // 使用 low[v] 的值更新 low[u]:subtree(v) 能够达到的栈点,subtree(u) 肯定也可以
        } else...
    

    情况 2:(v) 已经被访问,而且 (v) 还在栈中。此时 (u ightarrow v) 有可能是前向边、返祖边、横叉边中任意一种。前向边不会对答案造成影响。因此直接比较即可。

        } else if (!scc[v]) { // 注意 scc[v] = 0 等价于 v 还未出栈
          low[u] = min(low[u], dfn[v]);
        }
    

    遍历完所有出边后,如果 (low[u] = dfn[u]),则将栈顶到 (u) 一段弹出。

      } // for
      if (low[u] == dfn[u]) {
        scc_cnt++;
        while (S.top() != u) {
          int v = S.top();
          scc[v] = scc_cnt;
          S.pop();
        }
        scc[u] = scc_cnt;
        S.pop();
      }
    }
    

    最后将所有代码整合在一起,就得到了 Tarjan 求强连通分量的模板代码。

    int dfn[kMaxN], low[kMaxN], dfn_cnt;
    int scc[kMaxN], scc_cnt;
    stack<int> S; // 存储节点的栈
    void Tarjan(int u) {
      dfn[u] = low[u] = ++dfn_cnt;
      S.push(u);
      for (int i = 0; i < G.arcs[u].size(); i++) {
        int v = G.arcs[u][i];
        if (!dfn[v]) {
          Tarjan(v);
          low[u] = min(low[u], low[v]); // 使用 low[v] 的值更新 low[u]:subtree(v) 能够达到的栈点,subtree(u) 肯定也可以
        } else if (!scc[v]) { // 注意 scc[v] = 0 等价于 v 还未出栈
          low[u] = min(low[u], dfn[v]);
        }
      }
      if (low[u] == dfn[u]) {
        scc_cnt++;
        while (S.top() != u) {
          int v = S.top();
          scc[v] = scc_cnt;
          S.pop();
        }
        scc[u] = scc_cnt;
        S.pop();
      }
    }
    

    本文至此结束。以下是 luogu P3387【模板】缩点 的 AC 代码。

    // luogu-judger-enable-o2
    #include <bits/stdc++.h>
    using namespace std;
    const int kMaxN = 10000 + 10;
    const int kMaxM = 100000 + 10;
    struct Graph {
      vector<int> arcs[kMaxN];
      void Add(int u, int v) {
        arcs[u].push_back(v);
      }
    };
    Graph G, dag;
    int n, m;
    int val[kMaxN];
    int dfn[kMaxN], low[kMaxN], dfn_cnt;
    int scc[kMaxN], scc_cnt, sum[kMaxN];
    stack<int> S;
    void Tarjan(int u) {
      dfn[u] = low[u] = ++dfn_cnt;
      S.push(u);
      for (int i = 0; i < G.arcs[u].size(); i++) {
        int v = G.arcs[u][i];
        if (!dfn[v]) {
          Tarjan(v);
          low[u] = min(low[u], low[v]);
        } else if (!scc[v]) { // 前向边、返祖边、横叉边
          low[u] = min(low[u], dfn[v]);
        }
      }
      if (low[u] == dfn[u]) {
        scc_cnt++;
        while (S.top() != u) {
          int v = S.top();
          scc[v] = scc_cnt;
          sum[scc[v]] += val[v];
          S.pop();
        }
        scc[u] = scc_cnt;
        sum[scc[u]] += val[u];
        S.pop();
      }
    }
    int dp[kMaxN];
    void Dfs(int u) {
      dp[u] = 0;
      for (int i = 0; i < dag.arcs[u].size(); i++) {
        int v = dag.arcs[u][i];
        if (dp[v] == -1) Dfs(v);
        dp[u] = max(dp[u], dp[v]);
      }
      dp[u] += sum[u];
    }
    int main() {
      scanf("%d %d", &n, &m);
      for (int i = 1; i <= n; i++)
        scanf("%d", &val[i]);
      for (int i = 1; i <= m; i++) {
        int u, v;
        scanf("%d %d", &u, &v);
        G.Add(u, v);
      }
      memset(dp, -1, sizeof(dp));
      for (int i = 1; i <= n; i++)
        if (!scc[i]) Tarjan(i);
      for (int u = 1; u <= n; u++)
        for (int i = 0; i < G.arcs[u].size(); i++)
          dag.Add(scc[u], scc[G.arcs[u][i]]);
      int ans = 0;
      for (int i = 1; i <= scc_cnt; i++)
        if (dp[i] == -1) {
          Dfs(i);
          ans = max(ans, dp[i]);
        }
      printf("%d
    ", ans);
      return 0;
    }
    
  • 相关阅读:
    关于DataGrid最后一页只有一行记录时,删除此记录出错的问题
    数据库开发个人总结(ADO.NET小结)
    MSSQL server数据库开发精典技巧
    愈强愈勇(奥运六星)
    中国与日本的生活对比 转)
    古龙妙语录
    DataGrid单击行时改变颜色
    VS.NET 2003 控件命名规范
    qt国际化
    Histogram matching using python, opencv
  • 原文地址:https://www.cnblogs.com/longlongzhu123/p/11299935.html
Copyright © 2011-2022 走看看