边三连通分量算法

题目链接

给出一张 \(n\) 个点 \(m\) 条边的无向图,可能不连通、有重边、有自环、有割边。求其所有极大的边三连通分量。

\(n, m \le 5 \times 10 ^ 5\)

论文太长了,还没看完,目前只看懂了算法步骤,一些证明还咕在后面。就先介绍一下步骤,正确性证明和时间复杂度证明等我看懂以后补上来。附一个论文原地址:A Simple 3-Edge-Connected Component Algorithm,来源选 ResearchGate 那个可以免费下载。本文内图片均出自这篇论文。

由于这个算法的核心在于其中的 Absort-Eject 操作,我习惯称其为 Absorb-Eject 算法。Absorb-Eject 算法的思想与求点双、边双的 Tarjan 算法类似,都是利用算法过程中建出的 dfs 树,求出点之间的连边情况。故为了更清晰地弄懂这个算法,最好对点双、边双的 Tarjan 算法有一定的理解。

为了减少讨论,我们需要先删除掉原图上一些可有可无,但会导致一些麻烦的分类情况的边:自环和割边。

  • 自环:显然存在一个最优方案使得连通的三条路径都不包含自环,故自环可删。
  • 边三连通分量一定是边双连通分量,因此割边两端的边不可能属于同一个边三连通分量,故割边可删。

经过这样预处理转化后,我们将原图变成了若干无自环的边双连通分量的连通块。那么以下的算法过程,均在这样的边双中进行。

首先,对限制条件进行一定的观察:两个点 \(u, v\) 在相同的边三内,当且仅当不存在一个边对 \((e_1, e_2)\),满足将原图的 \(e_1, e_2\) 割开以后,\(u\)\(v\) 不连通。

再加上这张图内没有割边,我们可以定义一个类似割边的定义:切边。我们称一条边 \(e\) 是切边,当且仅当它能够与另外一条边 \(e'\) 配合,把原图割成两个连通块。那么,对于一条边 \(e = (u, v)\),若 \(e\) 是一条切边,则 \(u, v\) 一定不在一个相同的边三内;若 \(e\) 不是一条切边,则 \(u, v\) 一定在一个相同的边三内。所以我们只需要把原图中所有切边删去,剩下的边就将原图连成了若干边三。

于是我们明确了算法的目的:确定每条边是否为切边。

这个算法的核心步骤是 Absorb-Eject 操作,可译为吞吐操作。Absorb 会在一条边 \((w, u)\) 上进行,表示 \(w\)\(u\) 吞并。吞并时,\(u\) 消失,所有与 \(u\) 相邻的边 \((x, u)\)(除了 \((w, u)\) 以外),都变成与 \(w\) 相邻的边 \((x, w)\)。特殊地,如果 \(u\) 的点度为 \(2\) (注意此时的点度是吞并后形成的新图的点度,而点 \(u\) 也可能已吞并了若干个点),那么可以割开这两条边使得 \(u\) 与外界不连通,说明 \(u\)\(u\) 已吞并过的点是一个单独的边三,就让 \(w\)\(u\) 吐出来,而吐出来的 \(u\) 失去所有相邻的边。

形式化来讲,对于每个点 \(u\),定义其已吞并点集为 \(\sigma(u)\),初始时,\(\sigma(u) = \{u\}\)。进行到目前的图为 \(G' = (V', E')\),进行吞吐的边为 \((w, u)\)。那么进行一次 Absorb-Eject 操作后,图会变成 \(G' / e = (V'', E'')\)。其中 \(E'' = E' \setminus E_u \cup E_{w ^ +}\),其中 \(E_u\) 表示 \(G'\) 中与 \(u\) 相邻的边,\(E_{w ^ +} = \{ f' = (w, z) \mid \exists f \in E_u,\text{ such that } f = (u, z) \text{ for some } z \in V' - \{w\} \}\)。而 \(V''\) 需要分类讨论,若 \(deg_{G'}(u) = 2\),则 \(u\) 会被 \(w\) 吐出来,那么 \(V'\) 没变;若 \(deg_{G'}(u) \neq 2\),则 \(u\)\(w\) 吸收,\(V'' = V' - \{u\}\)\(\sigma(w) = \sigma(w) \cup \sigma(u)\)

由于可以证明(第一个待补证明的坑),若 \(deg_{G'}(u) \neq 2\),则 \((w, u)\) 一定不是切边,也就是 \(w, u\) 一定在一个边三内。换句话说,就是 \(\sigma(w)\) 就是 \(w\) 所代表的一个原图上的一个边三。在进行若干次吞并后,所有的边都消失了,变成若干独立的点。则每个独立的点就代表着原图上一个极大边三连通分量,就是我们想求的东西。

pic1.png

以上是核心步骤 Absorb-Eject。我们接下来用一个类似 Tarjan 算法的 dfs 过程,配合着 Absorb 操作,将原图一步步变成这样没有边的图,得到每一个表示极大边三连通分量的独立点。

又有一个奇怪的结论(第二个待补证明的坑):递归完一个子树 \(u\) 结束回溯后,子树 \(u\) 内所有仍未确定是否为切边的边形成了一条一端为 \(u\) 的路径,也即修改后的图形成了一条一端为 \(u\) 的路径和若干代表者边三连通分量的独立点。我们称 \(u\) 上挂着的这条路径为 \(u\) - path,记 \(P_u\),我们需要在 dfs 的过程中维护 \(P_u\),最终到达根 \(r\) 时的 \(P_r\) 会为空,也就是再没有未确定是否为切边的边,就结束了我们的算法过程。

dfs 过程中,同样记录 \(low\)\(dfn\)\(dfn(u)\) 表示点 \(u\) 在 dfs 序中的编号,\(low(u)\) 表示 \(u\) 经过最多一条返祖边能到达的 \(dfn\) 最小值,那么有 \(low(w) = \min(\{low(u) \mid u \text{ is a child of } w\} \cup \{ dfn(w') \mid (w, w') \text{ is a back-edge} \} \cup \{dfn(w)\})\)。我们令此时 dfs 到了一个点 \(w\),枚举其相邻边,分类讨论更新 \(low\)\(P_w\)

  • \((w, u)\) 是一条没用的边,即 \(w = u\),或 \((w, u)\) 为割边,或 \(u\)\(w\) 的父亲且 \(w\) 是从 \(u\) 的这条边过来(就是父边)。不管,continue。
  • \((w, u)\) 是一条树边。递归执行 \(dfs(u)\)。首先判断一下 \(deg_{G'}(u)\) 是否为 \(2\),如果等于 \(2\) 那么要先把 \(u\) 独立吐出来形成一个单独的边三,同时把 \(u\)\(P_u\) 中去掉,\(P_u = P_u - u\)。接着看 \(low(u)\) 是否会对 \(low(w)\) 产生贡献:
    • \(low(u) < low(w)\),大概由于增加了一条 \(u \to low(u) \to low(w) \to P_w\) 的路径,原本还未确定的 \(P_w\) 可以确定为不是切边了,于是让 \(w\) 将原本的 \(P_w\) 吞并掉,然后用 \(w + P_u\)\(P_w\) 替换掉。
      pic2.png
    • \(low(u) \ge low(w)\),类似上一条,原本还未确定的 \(P_u\) 可以确定为不是切边了,让 \(w\)\(P_u\) 吞并掉,保持 \(P_w\) 不变。
  • \((w, u)\) 是一条返祖边。若再满足 \(dfn(u)\) 可以更新 \(low(w)\),那么 \(P_w\) 可以确定为不是切边了,这时让 \(w\)\(P_w\) 吞并掉,然后 \(P_w\) 清空。
  • \((w, u)\) 是一条前向边。由于 \((w, u)\) 这条边的存在,\(u\) 一定落在 \(P_w\) 上。那么这时 \(P_w\)\([w \cdots u]\) 部分可以确定为不是切边了,就让 \(u\)\(P_w\)\([w \cdots u]\) 部分吞并掉,剪掉 \(P_w\) 的这段前缀。
    pic3.png

由于 \(low(r) = 1\),所有的树边都会到 \(low(u) \ge low(w)\) 这条,因此 \(P_r\) 保持为空。也就是上面所说的,递归到根结束后,就确定了每条边是否为切边,算法顺利完成。

贴上论文中给出的伪代码:

pic4.png

最后,注意到图变化的时候边不需要显式地维护,只要维护每个点的相邻点度就好了。代码能比较容易地写出来。

我用了并查集维护一个点的集合,所以时间复杂度 \(\mathcal{O}((n + m) \log n)\)。实现细致一点可以把并查集扔掉,时间复杂度为 \(\mathcal{O}(n + m)\)

#include <algorithm>
#include <cstdio>
#include <cstring>
#include <utility>
#include <vector>

const int MaxN = 500000, MaxM = 500000;

struct graph_t {
  int cnte;
  int head[MaxN + 5], to[MaxM * 2 + 5], next[MaxM * 2 + 5];

  graph_t() { cnte = 1; }

  inline void addEdge(int u, int v) {
    cnte++; to[cnte] = v;
    next[cnte] = head[u]; head[u] = cnte;
  }
};

struct union_find {
  int par[MaxN + 5];
  union_find() { memset(par, -1, sizeof par); }

  int find(int x) { return par[x] < 0 ? x : par[x] = find(par[x]); }

  inline void merge(int u, int v) {
    int p = find(u), q = find(v);
    if (p == q) return;
    par[p] += par[q];
    par[q] = p;
  }
};

int N, M;
graph_t Gr;

class two_edge_connect {
private:
  int low[MaxN + 5], dfn[MaxN + 5], dfc;
  int stk[MaxN + 5], tp;
  int bel[MaxN + 5], s;

  void dfs(int u, int fe) {
    low[u] = dfn[u] = ++dfc;
    stk[++tp] = u;
    for (int i = Gr.head[u]; i; i = Gr.next[i]) {
      if ((i ^ fe) == 1) continue;
      int v = Gr.to[i];
      if (dfn[v] == 0) {
        dfs(v, i);
        low[u] = std::min(low[u], low[v]);
      } else
        low[u] = std::min(low[u], dfn[v]);
    }
    if (low[u] == dfn[u]) {
      s++;
      for (;;) {
        int v = stk[tp--];
        bel[v] = s;
        if (u == v) break;
      }
    }
  }

public:
  void init() {
    memset(dfn, 0, sizeof dfn);
    dfc = tp = s = 0;
    for (int i = 1; i <= N; ++i)
      if (dfn[i] == 0) dfs(i, 0);
  }

  inline bool isbridge(int u, int v) {
    return bel[u] != bel[v];
  }
};

class three_edge_connect {
private:
  two_edge_connect bcc;
  union_find uf;
  int low[MaxN + 5], dfn[MaxN + 5], end[MaxN + 5], dfc;
  int deg[MaxN + 5];

  inline bool insubtree(int u, int v) {
    if (dfn[u] <= dfn[v] && dfn[v] <= end[u]) return true;
    else return false;
  }

  inline void absorb(std::vector<int> &path, int u, int w = 0) {
    while (path.empty() == false) {
      int v = path.back();
      if (w > 0 && insubtree(v, w) == false) break;
      path.pop_back();
      deg[u] += deg[v] - 2;
      uf.merge(u, v);
    }
  }

  void dfs(int u, int fe, std::vector<int> &pu) {
    low[u] = dfn[u] = ++dfc;
    for (int i = Gr.head[u]; i; i = Gr.next[i]) {
      int v = Gr.to[i];
      if (u == v || bcc.isbridge(u, v) == true) continue;
      deg[u]++;
      if ((i ^ fe) == 1) continue;
      if (dfn[v] == 0) {
        std::vector<int> pv;
        dfs(v, i, pv);
        if (deg[v] == 2) pv.pop_back();
        if (low[v] < low[u]) {
          low[u] = low[v];
          absorb(pu, u);
          pu = pv;
        } else absorb(pv, u);
      } else {
        if (dfn[v] > dfn[u]) {
          absorb(pu, u, v);
          deg[u] -= 2;
        } else if (dfn[v] < low[u]) {
          low[u] = dfn[v];
          absorb(pu, u);
        }
      }
    }
    end[u] = dfc;
    pu.push_back(u);
  }

public:
  void init() {
    memset(dfn, 0, sizeof dfn);
    memset(deg, 0, sizeof deg);
    dfc = 0;
    bcc.init();
    for (int i = 1; i <= N; ++i) {
      if (dfn[i] == 0) {
        std::vector<int> pi;
        dfs(i, 0, pi);
      }
    }
  }

  std::vector< std::vector<int> > getall() {
    std::vector< std::vector<int> > res(N), ans;
    for (int i = 1; i <= N; ++i) {
      int x = uf.find(i);
      res[x - 1].push_back(i);
    }
    for (int i = 0; i < N; ++i)
      if (res[i].empty() == false) ans.push_back(res[i]);
    return ans;
  }
};

void init() {
  scanf("%d %d", &N, &M);
  for (int i = 1; i <= M; ++i) {
    int u, v;
    scanf("%d %d", &u, &v);
    Gr.addEdge(u, v);
    Gr.addEdge(v, u);
  }
}

inline bool cmp(const std::vector<int> &x, const std::vector<int> &y) { return x[0] < y[0]; }

void solve() {
  static three_edge_connect tcc;
  tcc.init();
  std::vector< std::vector<int> > ans = tcc.getall();
  for (int i = 0; i < (int) ans.size(); ++i)
    std::sort(ans[i].begin(), ans[i].end());
  std::sort(ans.begin(), ans.end(), cmp);
  printf("%d\n", (int) ans.size());
  for (int i = 0; i < (int) ans.size(); ++i) {
    int s = (int) ans[i].size();
    for (int j = 0; j < s; ++j)
      printf("%d%c", ans[i][j], " \n"[j == s - 1]);
  }
}

int main() {
  init();
  solve();
  return 0;
}

posted @ 2020-06-18 15:37  Tweetuzki  阅读(819)  评论(1编辑  收藏  举报