有向图上的深度优先搜索

深度优先探索,深度优先搜索

对一个有向图的深度优先探索depth-first exploration有规律地访问它的顶点和走过它的边。走过一条边分为两个阶段,先是在这条边上前进advance,以后还要在这条边上回退retreat

维护一个当前点current vertex ,最初是 null。把所有顶点标记为未访问过,所有边标记为未走过。

重复下列三种操作之一直到所有点都访问过,所有边都走过:

  1. 当前点 是 null 且有顶点未访问过。令 是任一未访问过的点。访问 。这开启一次以 为起点的深度优先搜索depth-first search,将访问所有从 可达的未访问过的顶点,走过所有从这些点发出的边。
  2. 当前点不是 null 且有未走过的出边。任选一条这样的边 ,不妨设是从 的,沿着这条边前进。若 已访问过,立即在 回退;否则以 为进入点 树边,把当前点 置为 访问这新的
  3. 当前点 不是 null 且它没有没走过的出边。若 是此次 DFS 的起点,将当前点 置为 null。否则有一条树边 进入点 ,设 的起点是 ;在 回退,把当前点 置为

深度优先搜索的性质

我们常把深度优先搜索简称为 DFS。

引理 D1 一次深度优先探索产生一族有根树,以每次 DFS 的起点为根,它们的边是此次探索定义的树边。每个树的顶点是在起于它的根的那次 DFS 期间被访问的那些点。

证明

  • 每个点从未访问变成已访问只有一次,所以至多有一条进入它的树边。
  • 每条新的树边都进入一个未访问过的点,此时还没有从它发出的树边,所以树边不成环。
  • 每个点或者是一次 DFS 的起点,那样就没有树边进入它而它是一个树的根;或者不是起点,那样就有树边进入它而它不是根。
  • 是从 的树边,则访问了点 的那次 DFS 也访问了点

DFS 树,DFS 森林

我们把一次深度优先探索产生的那些有根树称为 DFS 树。它们一起构成这次探索产生的 DFS 森林

先序访问,后序访问,时间戳

深度优先探索以先序访问每个 DFS 树的顶点。我们称这些访问为先序访问preorder visit初访。此外,我们还考虑深度优先探索对每个顶点 的第二次隐含的访问,就是上述情况 3,当 是当前点但它没有未走过的出边时。我们把这些访问称为后序访问postorder visit终访,因为在每个 DFS 树上,这些访问发生的顺序是后序。

我们给这 次初访和终访每个赋予一个数,称为时间戳。我们把点 的初访和终访的时间戳分别记作 。这些时间戳的值不重要,只要能反映顺序:若一次访问发生在另一次访问之前,则前者的时间戳必小于后者。

回边,前向边,横叉边

一次深度优先探索把除树边和自环之外的边 分成三类。

  • 回边 的祖先。也称返祖边。
  • 前向边 的后代。
  • 横叉边 无祖先——后代关系。

当前路径

DFS 维护一条从起点到当前点,由树边构成的路径。在一条树边上前进使此路径多一条边,而在一条树边上回退使它少一条边。我们称此路径为当前路径

引理 D2 每个顶点 在当前路径上是从它刚要被初访时开始直到它刚被终访过为止。在这段时间内,DFS 在 的每条出边上前进和回退。

证明:一个点 被初访是在它变成当前点并加入当前路径时。自此它在当前路径上直到被终访。如果在 的某条出边上的前进还没发生,点 的终访不可能发生。在 的出边上前进时, 是当前点。假设在边 上的前进刚刚发生。若 访问过,在边 上的回退随即发生。否则 成为树边而 变成当前点。在点 再次成为当前点之前 不会被终访,而那只能发生在 被终访以后。在 被终访之后,在 上的回退立即发生,故而是在 被终访之前。

引理 D3 下列四个条件等价:

  • (1) 顶点 是顶点 的后代
  • (2)
  • (3)
  • (4)

证明:若 的后代,则 被加入和删除当前路径时 都在当前路径上。根据引理 D2,。因此 (1) 蕴含 (2),(3),(4)。

,则 被初访时 在当前路径上,故 的后代;即 (2) 蕴含 (1)。类似地,若 被终访时 在当前路径上,故 的后代;即 (3) 蕴含 (1)。最后,(4) 蕴含 (2) 和 (3) 故也蕴含 (1)。

引理 D4 是点 的后代当且仅当在 刚要被初访时有一条从 的路径,上面都是未访问过的点。

证明

  • 的后代,根据引理 D3,在 刚要被初访时,所有从 走树边到 的路径上的点都未访问。
  • 反之,设在 刚要被初访时有一条从 的路径 ,上面的点都未访问。我们证明 的后代。
    • 若边 上而 的后代,则据引理 D2 有 ,据引理 D3 又有 ,故 。又根据条件, 刚要被初访时 尚未被访问,故 。综上,,所以 的后代。
    • 于是可对路径 的顶点数用归纳法,因为在 上只有一个点 时, 是自身的后代,结论成立。

引理 D5 是顶点, 是从 的边。
(1) 若 的后代而 不是,则
(2) 若 的后代而 不是,则

证明:此引理是引理 D3 的推论。

(1) 设 的后代且 。由于 的后代,在边 上从 前进到 发生在 的终访之前,故而 的初访也发生在 的终访之前。即有 。根据引理 D3,此不等式和 蕴含 的后代。

(2) 设 的后代且 。由于在边 上回退是在 被终访之前,有 。由于 的后代,有 。这些不等式合起来给出 。据引理 D3, 的后代。

引理 D6 若有从 的横叉边,则

证明:由于 不是 的后代,以 代入引理 D5 (1) 给出 。而根据引理 D3, 蕴含 的后代,矛盾。故而

DFS 的递归实现

用 DFS 进行拓扑排序

确定是 dag

vector<int> g[maxn];
bool vis[maxn];
vector<int> p;
void dfs(int u) {
  if (vis[u]) return;
  vis[u] = 1;
  for (int v : g[u])
    dfs(v);
  p.push_back(u);
}

void topo(int n) {
  for (int i = 1; i <= n; i++)
    dfs(i);
  reverse(p.begin(), p.end());
}

可能不是 dag

vector<int> g[maxn];
int vis[maxn];
vector<int> p;
bool dfs(int u) {
  if (vis[u] == -1) return false;
  if (vis[u] == 1) return true;
  vis[u] = -1;
  for (int v : g[u])
    dfs(v);
  p.push_back(u);
}

void topo(int n) {
  for (int i = 1; i <= n; i++)
    if (!dfs(i)) {
      p.clear();
      return;
    }
  reverse(p.begin(), p.end());
}

强连通分量

在有向图 上,称两顶点 相互可达,若有从 路径和从 的路径。相互可达是等价关系,它把顶点集分成等价类,称为 强连通分量strongly connected component强分量strong component。只有一个强连通分量的有向图称为强连通的strongly connected

我们常把强连通分量简写为 SCC。

若把 的每个 SCC 看成一个点,就得到 的 SCC 图,它是一个 dag。

强连通分量的性质

SCC 对路径封闭

引理 S1 在同一个 SCC 里,则任一从 的路径上的顶点全在 里。

证明:若 里,则有一条从 的路径 。若 在从 的路径 上,则 在由 构成的环上,因此 里。

引理 S2 在一个 SCC 里, 最小的点 也是 最大的点,这个点是 里每个点的祖先。

证明:此引理是引理 D4 的推论。 最小的点 里第一个被访问的点。根据引理 D4, 里所有点的祖先。根据引理 D3, 也是 最大的点。

首领,随从

我们把一个 SCC 里 最小的点称为首领,而其余点称为首领的随从
换言之,一个 SCC 的首领就是其中最早被 DFS 发现的点。

每个 DFS 树的根一定是它所属的 SCC 的首领,但是首领未必是 DFS 树的根。

引理 S3 不是它所属的 SCC 的首领,则 的父节点 也在 里。

证明:设 的首领。树边 和一条从 的路径构成一条从 的路径。根据引理 S2, 是首领 的后代,所以从 走树边可以到 ,而且会经过 。所以 里。

引理 S4 强连通分量把 DFS 树划分成子树:若我们从 DFS 森林里删除每一条进入 SCC 首领的树边,将会得到一族有根树,每个有根树的根是一个 SCC 的首领,顶点是此首领和其随从。

引理 S5 把强连通分量的首领们按后序从大到小排列给出强连通分量的拓扑序:若 是一条从 的边, 分别是 所属的 SCC 的首领,则

证明:若 的后代,则 在同一个强连通分量里,因为边 和一条从 的路径和从 的由树边构成的路径成一个环。于是 而引理成立。若 不是 的后代,则根据引理 D3 和 D5 有

引理 S6 顶点 是 SCC 的首领当且仅当 所属的 SCC 之内不存在一条路径从 出发走若干条树边和一条非树边到达顶点

证明:令 所属的 SCC。必要性。设 的首领,则根据首领的定义,在 之内没有路径从 到顶点

充分性。若 不是 的首领,设 的首领,则 而且在 里有一条从 的路径 。设 是路径 上第一个不是 的后代的顶点,设边 上进入 的边,则 不是树边。由于 的后代,有一条从 走树边到 的路径。由于 不是 的后代,根据引理 D5,有

:在引理 S6 的表述中,要求“在 所属的 SCC 之内”是不可缺少的,不然引理不成立。

用 DFS 找强连通分量

  • Kosaraju 算法(两次 DFS)

  • Tarjan 算法(一次 DFS)

Kosaraju 的 SCC 算法

首先从 开始 DFS,将搜到 ;然后从 开始 DFS,搜到 ;再从 开始搜,搜到 ;最后从 开始搜,搜到 。以这样的顺序进行 DFS,每次得到一个 SCC。

若从 开始 DFS,将搜完整个图。换言之,这次 DFS 把所有 SCC 混在一起了。

如果我们按 SCC 图的拓扑序的逆序进行 DFS,就能每次只得到一个 SCC。

Kosaraju 算法便是基于这个思想的。

设有向图 个 SCC,。对 进行 DFS,设 的首领是 。对于两个不同的 SCC ,若在 ,则对于每个顶点 都有

Kosaraju 的 SCC 算法有三步:

  1. 进行 DFS。
  2. 构建 反图(把 的边方向反转得到的图)
  3. 上按 从大到小的顺序进行 DFS。每次 DFS 访问的点就是一个强连通分量。

实现 Kosaraju 的 SCC 算法

vector<int> g[maxn];
bool vis[maxn];
vector<int> s;

void dfs(int u) {
  if (vis[u]) return;
  vis[u] = true;
  for (int v :g[u])
    dfs(v);
  s.push_back(u);
}
//g2是g的反图
vector<int> g2[maxn];
int scc_cnt, sccno[maxn];

void dfs2(int u) {
  if (sccno[u]) return;
  sccno[u] = scc_cnt;
  for (int v : g2[u])
    dfs2(v);
}
void find_scc(int n) {
  scc_cnt = 0;
  s.clear();
  memset(sccno, 0, sizeof sccno);
  memset(vis, 0, sizeof vis);

  for (int i = 1; i <= n; i++)
    dfs(i);
  
  for (int i = n - 1; i >= 0; i--)
    if (!sccno[s[i]]) {
      scc_cnt++;
      dfs2(s[i]);
    }
}

Tarjan 的 SCC 算法

Tarjan 的 SCC 算法只需要做一次 DFS 就能找出每个 SCC。

利用引理 S6,定义点 low 值

  • 出发走若干条树边和至多一条非树边能到达的 在同一 SCC 里的点 的最小值。

是 SCC 的首领当且仅当

可是怎么知道 是否在同一 SCC 里呢?
Tarjan 的 SCC 算法也是以 SCC 的逆拓扑序找出每个 SCC 的,每找出一个 SCC 之后,就把其中的点标记一下,这样如果有从 的路径而 没被标记,那么它就一定与 在同一 SCC 里。

怎么找出每个 SCC 里的点呢?

准备一个栈。当点 被初访时,把 放进栈里。当点 被终访时, 已经算出来了,若 是它所属的 SCC 的首领,此时栈里 以上的点就是 的全部随从。把这些点弹出栈并打上标记。

实现 Tarjan 的 SCC 算法

vector<int> g[maxn];
int dfn[maxn], low[maxn], sccno[maxn];
int scc_cnt, num;
stack<int> s;

void dfs(int);

void tarjan_scc(int n) {
  for (int i = 1; i <= n; i++)
    dfn[i] = sccno[i] = 0;
  scc_cnt = num = 0;
  for (int i = 1; i <= n; i++)
    if (!dfn[i])
      dfs(i);
}
void dfs(int u) {
  s.push(u);
  dfn[u] = low[u] = ++num;

  for (int v: g[u])
    if (!dfn[v]) {
      dfs(v);
      low[u] = min(low[u], low[v]);
    } else if (!sccno[v])//v和u在同一SCC
      low[u] = min(low[u], dfn[v]);

  if (low[u] == dfn[u]) {
    scc_cnt++;
    while (1) {
      int v = s.top(); s.pop();
      sccno[v] = scc_cnt;//打标记
      if (v == u) break;
    }
  }
}

Tarjan 的 SCC 算法的另一实现

把上一页的 DFS 做如下改动也是正确的:

解释:对每个点 ,当 被初访时, 被赋予初始值 ,此后若 改变,只会变小。换言之,自从 被初访之后总有 。另一方面,不论 如何变,它一定等于 所属的 SCC 里某个点 。因此仍然有

  • 是 SCC 的首领当且仅当在 被终访时

注意:这种写法算出的 low 值是上面所说的含义。详见后面的回退路径。

简化代码

在上一实现的基础上,如果在找到一个 SCC 时,把其中的点的 low 值改为充分大的数,它就影响不到以后找到的 SCC。采用这种想法,代码可以简化

void dfs(int u) {
  dfn[u] = low[u] = ++num;
  s.push(u);
  for (int v: g[u]) {
    if (!dfn[v]) dfs(v);
    low[u] = min(low[u], low[v]);
  }
  if (low[u] == dfn[u]) {
    scc_cnt++;
    while (1) {
      int v = s.top(); s.pop();
      low[v] = n;
      sccno[v] = scc_cnt;
      if (v == u) break;
    }
  }
}

空间优化:省去 dfn 数组

作为 DFS 序,当发现点 的 low 值变小时,就把新的 low 值的二进制最低位设置为 1。

void dfs(int u) {
  s.push(u);
  num += 2; low[u] = num;
  for (int v: g[u]) {
    if (!low[v]) dfs(v);
    if (low[v] < low[u]) low[u] = low[v] | 1;
  }
  if ((low[u] & 1) == 0) {
    scc_cnt++;
    while (1) {
      int v = s.top(); s.pop();
      low[v] = 2 * n;
      sccno[v] = scc_cnt;
      if (v == u) break;
    }
  }
}

模板题 强连通分量

给你一个有 个点和 条边的有向图。第 条边是 。可能有重边和自环。

将此图分解为 SCC 并以拓扑序输出这些 SCC。

限制

代码

const int maxn = 5e5;
vector<int> g[maxn];
int low[maxn], num;
vector<vector<int>> scc;
stack<int> s;

void dfs(int u) {
  num += 2;
  low[u] = num;
  s.push(u);
  
  for (int v : g[u]) {
    if (!low[v]) dfs(v);
    if (low[v] < low[u])
      low[u] = low[v] | 1;
  }
  
  if ((low[u] & 1) == 0) {
    vector<int> cur;
    while (1) {
      int v = s.top(); s.pop();
      cur.push_back(v);
      low[v] = INT_MAX;
      if (v == u) break;
    }
    scc.push_back(cur);
  }
}
int main() {
  int n, m;
  cin >> n >> m;
  for (int i = 0; i < m; i++) {
    int a, b;
    cin >> a >> b;
    g[a].push_back(b);
  }

  for (int i = 0; i < n; i++)
    if (!low[i])
      dfs(i);

  cout << scc.size() << '\n';
  for (int i = scc.size() - 1; i >= 0; i--) {
    cout << scc[i].size();
    for (int j : scc[i])
      cout << ' ' << j;
    cout << '\n';
  }
}

回退路径 ⭐

我们称一条路径为回退路径,若 DFS 在这条路径的边上回退的顺序与边在路径上的顺序相反。

特别地,从一个点到自身的没有边的路径是回退路径。

走若干条树边再走一条非树边 的路径 是回退路径,因为当在边 上回退时,路径 扣掉边 是当前路径或当前路径的一部分,而 是当前节点。在 的边当中,边 上的回退最先发生,随后发生的是 上的树边上的回退,顺序与这些树边在 上的顺序相反。因此引理 S6 的证明里构造的路径是回退路径,于是给出以下引理:

引理 S7 顶点 是 SCC 的首领当且仅当 所属的 SCC 之内不存在 到顶点 回退路径

我们可利用上述引理 S7 来找出 SCC 的首领。

  • 把首访时间戳 定义为从 开始的连续整数。
  • 对每个顶点 ,我们计算 ,它等于 的最小值,点 满足
    • 出发走一条回退路径可到达 并且 在同一 SCC 里。

如何计算 low 值?

  • 被初访时,初始化
  • 当在边 上回退时,更新
  • 被终访时,在 的出边上的回退都已发生了, 就算出来了。
  • 因此, 是 SCC 的首领当且仅当在 被终访时

如何判断两点是否在同一 SCC 里?

  • 每当找出一个 SCC 时,就把其中的点的 low 值设置为一个充分大的数。这样一来,这些 low 值就不影响以后的 low 值更新;当在 的出边 上回退时就可以无条件地更新 不需要判断 是否在同一 SCC 里。

刚要 or 即将,哪个词更合适?

TODO: 加图