无向图上的深度优先搜索

depth-first search,DFS

动画

树边,回边

是连通图。从顶点 出发,对 做 DFS,把 的边分成两类:

  • 树边:导向新发现的点的边。
  • 回边:导向已发现的点的边。

树边构成 的生成树,称为 DFS 树,是以 为根的有根树。

回边的两个端点在 DFS 树里是祖先—后代关系。

DFS 序

顶点 被 DFS 发现的序号称为 的DFS 序,记作

DFS的代码实现

简易DFS

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

算DFS序

vector<int> g[maxn];
int dfn[maxn];
int num = 0;
void dfs(int u) {
    if (dfn[u]) return;
    dfn[u] = ++num;
    for (int v : g[u])
        dfs(v);
}

树上DFS

vector<int> g[maxn];
void dfs(int u, int p) {
    // 访问点u
    for (int v : g[u])
        if (v != p)
            dfs(v, u);
}

观察

在 DFS 树里,节点的DFS序有何特点?

一般无向图上的DFS

是连通的无向图。考虑对 进行 DFS,可见

  • 自环定为回边。
  • 一组重边要么都定为回边,要么其中一条定为树边而其余定为回边。

DFS 与二分图检验

vector<int> g[maxn];
int color[maxn];
bool dfs(int u, int c) {
  if (color[u])
    return color[u] == c;
  color[u] = c;
  for (int v : g[u])
    if (!dfs(v, -c))
      return false;
  return true;
}

bool test_bipart(int n) {
  for (int i = 1; i <= n; i++)
    if (!color[i] && !dfs(i, 1))
      return false;
  return true;
}

模板题

DFS 与图的结构

对图进行 DFS 可获得关于图的结构的信息。

  • 割点,桥
  • 2-连通图的性质
  • 双连通分量
  • 边双连通分量

复习:割点,桥

是图, 上一顶点, 上一条边。若从图 里删除点 之后,连通块的数量增加,则 割点cutvertex。若从图 里删除边 后,连通块的数量增加,则 bridge。桥也称割边


此图有割点 和桥

观察

桥必是树边。什么样的树边是桥?

什么样的顶点是割点?

桥,割点与 DFS 树

是连通图, 的一个以 为根的 DFS 树。

  • 是树边且 的父节点。边 是桥当且仅当没有回边从子树 中连向 的祖先。

  • 是割点当且仅当 有不止一个子节点。

  • 设点 不是根, 的父节点。 是割点当且仅当 有“脆弱”的孩子 :子树 中没有回边连向 的“爷爷” 的祖先。

low 值

是连通图。对 做 DFS 得到 DFS 树。

对非根节点 ,要判断 的父边是不是桥或 的父节点是不是割点,我们需要知道从子树 里发出的回边连向的最“高”的祖先有多“高”。为此定义点 low 值

  • 在图 里从点 出发“向下”走若干条树边再“向上”走至多一条回边到达的点的 DFS 序的最小值。

:上述定义中,点的 DFS 序也可换为点在 DFS 树里的深度。

在简单无向图上算 low 值

用 DFS 序算 low 值

vector<int> g[maxn];
int dfn[maxn], low[maxn];
int num = 0;
//在DFS树里p是u的父节点
void dfs(int u, int p) {
    low[u] = dfn[u] = ++num;
    for (int v : g[u])
        if (!dfn[v]) {
            dfs(v, u);//uv是树边
            low[u] = min(low[u], low[v]);
        } else if (v != p)//pu是树边
            low[u] = min(low[u], dfn[v]);
}

用深度算 low 值

vector<int> g[maxn];
int depth[maxn];//点的深度
int low[maxn];
int num = 0;
//在DFS树里p是u的父节点
void dfs(int u, int p) {
    low[u] = depth[u] = depth[p] + 1;
    for (int v : g[u])
        if (!dfn[v]) {
            dfs(v, u);
            low[u] = min(low[u], low[v]);
        } else if (v != p)
            low[u] = min(low[u], depth[v]);
}

习题 P3388 割点

给你一个 个点 条边的无向图,点从 编号。求图的割点。

限制

代码

const int maxn = 2e4 + 5;
vector<int> g[maxn];
int dfn[maxn], low[maxn], num;
bool mark[maxn];

void dfs(int u, int p) {
  dfn[u] = low[u] = ++num;
  int nc = 0; //孩子数

  for (int v : g[u])
    if (!dfn[v]) {
      dfs(v, u);
      nc++;
      low[u] = min(low[u], low[v]);
      //或 if (p && low[v] >= dfn[u])
      if (p && low[v] > dfn[p])
        mark[u] = true;
    } else if (v != p)
      low[u] = min(low[u], dfn[v]);

  if (p == 0 && nc > 1)
    mark[u] = true;
}
void solve() {
  int n, m; cin >> n >> m;
  for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    g[u].push_back(v);
    g[v].push_back(u);
  }
  for (int i = 1; i <= n; i++)
    if (!dfn[i])
      dfs(i, 0);
  
  int cnt = 0;
  for (int i = 1; i <= n; i++)
    cnt += mark[i];
  cout << cnt << '\n';

  for (int i = 1; i <= n; i++)
    if (mark[i])
      cout << i << ' ';
  cout << '\n';
}

习题 P4334 Policija

给你一个有 个点 条边的简单连通无向图。点从 编号。

回答 个询问,询问有两种类型

  • 如果从图上删除连接点 和点 的那条边,点 和点 是否连通?
  • 如果从图上删除点 ,点 和点 是否连通?
  • 对于第一种询问
    • 且有边连接
  • 对于第二种询问
    • 互不相同。

解析

对所给的图做一次 DFS。

第一种询问,对于要删的边 ,不妨设 ,我们有

  • 删除边 不连通 是桥且 有且只有一个在子树 里。
  • 是桥

如何判断点 在不在子树 里?

第二种询问,若删除点 不连通,则在DFS树中, 当中有一个,不妨设是 ,在 的某个脆弱子树 里,而 不在子树 里。因此

  • 首先判断 是否在子树 里。
  • 若在,则要找出 的哪个子树里。(如何找?)
  • 假设 的子树 里,看 是不是脆弱子树,即是否 ;若然,再看 在是否在子树 里。

代码

const int maxn = 1e5 + 5;
int dfn[maxn], low[maxn], num = 0;
int last[maxn];

vector<int> g[maxn];
vector<int> child[maxn];

void dfs(int u, int p) { // p是u的父节点
  low[u] = dfn[u] = ++num;
  for (int v : g[u])
    if (dfn[v] == 0) {// uv 是树边
      child[u].push_back(v);
      dfs(v, u);
      low[u] = min(low[u], low[v]);
    } else if (v != p)// uv 是回边
      low[u] = min(low[u], dfn[v]);
  last[u] = num;
}

bool is_houdai(int a, int b) { //a是不是b的后代,a是否在子树b里
  return dfn[b] <= dfn[a] && dfn[a] <= last[b];
}

bool cmp(int a, int b) {
  return dfn[a] < dfn[b];
}

int which_child(int a, int b) { // 点a在点b的哪个孩子里
  if (dfn[a] <= dfn[b] || dfn[a] > last[b])
    return 0; //a不在b的子树里。
  auto it = upper_bound(child[b].begin(), child[b].end(), a, cmp);
  return *(it - 1);
}
int main() {
  int n, m; cin >> n >> m;
  for (int i = 0; i < m; i++) {
    int u, v; cin >> u >> v;
    g[u].push_back(v);
    g[v].push_back(u);
  }

  dfs(1, 0); // 点1是DFS的起点

  int Q; cin >> Q;
  while (Q--) {
    int t, a, b, c, d;
    cin >> t;
    int cut = 0;
    if (t == 1) {
      cin >> a >> b >> c >> d;
      //c和d是祖先——后代关系,令d是c的后代。
      if (dfn[c] > dfn[d]) swap(c, d);
      cut = low[d] == dfn[d] && (is_houdai(a, d) ^ is_houdai(b, d));
    } else {
      cin >> a >> b >> c;
      for (int x : {a, b}) {
        int y = which_child(x, c);
        if (y && low[y] >= dfn[c] && !is_houdai(a + b - x, y)) {
          cut = 1;
          break;
        }
      }
    }
    if (cut) cout << "no\n";
    else cout << "yes\n";
  }
  return 0;
}

2-连通图的性质

回忆 -连通的定义

称图 -连通的(),若 并且对每个满足 的子集 都有图 是连通的。

2-连通图就是至少有三个点且没有割点的连通图。根据定义有

性质 B1 设图 2-连通。对于 上任意不同的三点 ,图 上有从 且不经过 的路径。

性质 B2 设图 2-连通。删除 的任何一条边之后,图仍然连通。

证明:设 的边, 不连通。 上有不同于 的点 ,设在 所属的连通块是 。则 两点有且只有一个在 里,若不然 就不连通。不妨设 ,那么在 里, 不连通,这与 是 2-连通图矛盾。

性质 B3 设图 2-连通。 的任何两点 都在某个环上。换言之,对于 的任何两点 上有两条独立的 路径。

证明:任取 上两个不同的点 ,令 为从 的一条路径。对路径 的长度 用归纳法。

时, 上的一条边。任取 上除 之外的一点 ,令 为从 且不经过 的一条路径, 为从 且不经过 的一条路径。令 上第一个在 上的点。那么 就是一个环。

对于 ,设 。由归纳假设知图 上有经过点 和点 的环 。若 上,命题成立,否则情形如下图

任取一条从 且不经过 的路径 。设 和环 的第一个交点是 。如下图所示

就是一个经过点 和点 的环。

性质 B4设图 2-连通, 上任意三点。 上有从 且经过 的路径。

证明:根据性质 B3,任取一个经过点 和点 的环 。若 上,则命题成立。否则,根据性质 B1,任取一条从 且不经过 的路径 ,设 的最后一个交点,那么 就是一条路径。

性质 B5 设图 2-连通, 的任何两条边都在一个环上。

证明:设 上的两条边。在 中间加一个点 ,在 中间加一个点 ,得到图 仍然 2-连通。 上有经过 的环。把这个环上和 相连的两条边替换为 ,和 相连的两条边替换为 ,就得到图 上的一个包含边 的环。

双连通,块

没有割点的连通图是双连通的biconnected。据此, 都是双连通的。

一个极大双连通子图称为一个block双连通分量biconnected component。据此,每个双连通分量或者是一个极大 -连通子图,或者是桥(连同两个端点),或者是孤立点。反之,每个这样的子图也是双连通分量。

双连通分量的性质

  • 根据双连通分量的极大性, 的不同双连通分量至多共有一个顶点,也是 的割点。因此

    • 的每条边落在唯一的双连通分量里。
    • “属于同一双连通分量”是图 的边集上的等价关系。
    • 是它的双连通分量的并。
  • 的每个环都在 的某个双连通分量里。

  • 是图 的两条不同的边,
    属于 的同一个双连通分量 属于 的同一个环。

块图

的双连通分量构成了 的粗粒度结构。令 的割点集, 的双连通分量集。在 上有一个自然的二分图,上面的边形如 的割点, 的双连通分量,并且 。这个图称为 块图block graph

不难想象,连通图的块图是树。

圆方树

连通图的块图是树。这个树,英文称作 block-cut tree,而中国 OI 界称之为圆方树。这是由于在画块图时,有一种习惯是把割点画成圆形而把双连通分量画成方形。

:block-cut tree 是 block-cutvertex tree 的简写,即“块-割点 树”。

用 DFS 找双连通分量

是非平凡的连通图, 的一个 DFS 树, 的一个双连通分量。

  • 里, 生成 的一个子树
  • 的根是 ,则
    • 要么是割点要么是 的根。
    • 有且只有一个子节点。设此子节点是 ,有

双连通分量的标志

在 DFS 树中,满足 的非根节点 “开启”一个双连通分量。 的父边是此双连通分量里第一条被 DFS 走过的边。我们说 是一个双连通分量的标志

是非平凡的连通图。 的双连通分量的数量等于 的 DFS 树中满足 的非根节点 的数量。

路径经过的双连通分量

前面提到,图 的双连通分量构成了 的粗粒度结构。关于这一点,我们给出一个更为具体的结果:

是图 上两条从 的路径。路径 上的边依次所属的双连通分量和路径 上的边依次所属的双连通分量相同。

例题 双连通分量

给你一个有 个点和 条边的无向图,点从 编号。第 条边连接点 。此图可能有重边但没有自环。

把给你的图分解成双连通分量,并输出每个双连通分量里的点。

限制

代码

const int maxn = 5e5 + 5;
int dfn[maxn], low[maxn], num;
vector<vector<int>> bcc;
stack<int> s;

void dfs(int u, int p) {
  dfn[u] = low[u] = ++num;
  s.push(u);

  for (int v : g[u])
    if (!dfn[v]) {
      dfs(v, u);
      low[u] = min(low[u], low[v]);
    } else if (v != p)
      low[u] = min(low[u], dfn[v]);
  
  if (p != -1 && low[u] >= dfn[p]) {
    vector<int> cur;
    while (1) {
      int v = s.top(); s.pop();
      cur.push_back(v);
      if (v == u) break;
    }
    cur.push_back(p);
    bcc.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);
    g[b].push_back(a);
  }

  for (int i = 0; i < n; i++)
    if (g[i].empty())
      bcc.push_back({i});
    else if (!dfn[i])
      dfs(i, -1);
    
  cout << bcc.size() << '\n';
  for (auto b : bcc) {
    cout << b.size();
    for (int x : b)
      cout << ' ' << x;
    cout << '\n';
  }
}

循环的四种写法

找双连通分量的 DFS 的两种写法

习题 abc318_g Typical Path Problem

给定一个连通的简单无向图 。图 个点和 条边。点从 编号。

给定图 上三个不同的点 。判断是否有从 且经过 的简单路径。

限制
  • 两两不同。

解析

设以 为起点对图 做 DFS 得到以 为根的 DFS 树
设点 的父边所在的双连通分量的标志是点

以下条件等价:

  1. 上有从 经过 的路径。
  2. 上有一条从 的路径 满足 上有一条边跟 的父边在同一个双连通分量里。
  3. DFS 树上从 的路径 上有一条(树)边在 的父边所属的双连通分量里。
  4. 的父边在路径 上。
  5. 的后代。

解法:找到 ,看 是不是 的后代。

代码

const int maxn = 2e5 + 5;
vector<int> g[maxn];
int dfn[maxn], num;
int low[maxn], parent[maxn];

void dfs(int u, int p) {
  low[u] = dfn[u] = ++num;
  parent[u] = p;
  
  for (int v : g[u])
    if (!dfn[v]) {
      dfs(v, u);
      low[u] = min(low[u], low[v]);
    }
    else if (v != p)
      low[u] = min(low[u], dfn[v]);
}
int main() {
  int n, m, a, b, c;
  cin >> n >> m >> a >> b >> c;
  for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    g[u].push_back(v);
    g[v].push_back(u);
  }

  dfs(a, 0);

  while (low[b] < dfn[parent[b]])
    b = parent[b];
  while (c) 
    if (c == b) {
      cout << "Yes\n";
      return 0;
    } else
      c = parent[c];
  cout << "No\n";
}

另一种写法

const int maxn = 2e5 + 5;
vector<int> g[maxn];
int dfn[maxn], last[maxn], num;
int low[maxn], parent[maxn];

void dfs(int u, int p) {
  low[u] = dfn[u] = ++num;
  parent[u] = p;

  for (int v : g[u])
    if (!dfn[v]) {
      dfs(v, u);
      low[u] = min(low[u], low[v]);
    }
    else if (v != p)
      low[u] = min(low[u], dfn[v]);

  last[u] = num;
}
int main() {
  int n, m, a, b, c;
  cin >> n >> m >> a >> b >> c;
  for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    g[u].push_back(v);
    g[v].push_back(u);
  }

  dfs(a, 0);

  while (low[b] < dfn[parent[b]])
    b = parent[b];
  if (dfn[b] <= dfn[c] && dfn[c] <= last[b])
    cout << "Yes\n";
  else
    cout << "No\n";
}

扩展:处理多个询问。

的父边所在的双连通分量的标志是

在 DFS 树 里,路径 上有一条边在 的父边所在双连通分量里。

的父边在路径 上。

边双连通

不含有桥的连通图是边双连通的。据此,边双连通图就是 -边连通图以及

的一个极大边双连通子图称为 的一个边双连通分量。据此,一个边双连通分量或者是极大 -边连通子图,或者是孤立点。

边双连通分量的性质

  • 根据边双连通分量的极大性, 的两个不同的边双连通分量不相交, 里连接二者的边至多有一条,且是桥。
  • “属于同一个边双连通分量”是一个图的顶点集上的等价关系。
  • 的每个环都在某个边双连通分量里。
  • 的所有桥删除,剩下的图上的每个连通块就是 的边双连通分量。

用 DFS 找边双连通分量

  • 的每个边双连通分量的点在 DFS 树中张成一个子树,此子树的根 满足
  • 满足 的节点 “开启”一个边双连通分量。

是连通图。 的边双连通分量的数量等于满足 的顶点 的数量。

例题 边双连通分量

给你一个有 个点和 条边的无向图。第 条边连接点 。此图可能有自环或重边。将所给的图分解成边双连通分量。

限制

重边对边双连通分量是重要的

代码

const int maxn = 2e5 + 5;
struct Edge { int from, to; };
Edge e[maxn];
vector<int> g[maxn];

int dfn[maxn], low[maxn], num;
stack<int> s;
vector<vector<int>> bcce;

void dfs(int u, int pe) { //pe是u的父边
  low[u] = dfn[u] = ++num;
  s.push(u);

  for (int id : g[u]) {
    int v = e[id].from ^ e[id].to ^ u;
    if (!dfn[v]) { 
      dfs(v, id);
      low[u] = min(low[u], low[v]);
    }
    else if (id != pe)
      low[u] = min(low[u], dfn[v]);
  }

  if (low[u] == dfn[u]) {
    vector<int> cur;
    while (1) {
      int v = s.top(); s.pop();
      cur.push_back(v);
      if (v == u) break;
    }
    bcce.push_back(cur);
  }
}
int main() {
  int n, m;
  cin >> n >> m;
  for (int i = 0; i < m; i++) {
    int a, b;
    cin >> a >> b;
    e[i] = {a, b};
    g[a].push_back(i);
    g[b].push_back(i);
  }

  for (int i = 0; i < n; i++)
    if (!dfn[i])
      dfs(i, -1);

  cout << bcce.size() << '\n';
  for (auto b : bcce) {
    cout << b.size();
    for (int x : b)
      cout << ' ' << x;
    cout << '\n';
  }
}

--- # 把无向图当作有向图 从应用来看,宜在有向图上讨论 DFS。为此,我们把无向图也当作有向图:把无向图的每条边看作有两个方向。具体地,设 $e=xy$ 是边,$e$ 一身两任,既是有向边 $x\to y$,也是有向边 $y \to x$;换言之,$e$ 既是 $x$ 的出边,也是 $y$ 的出边。 ![h:50](directed_undirected.svg)

图 $G$ 的顶点被 DFS 发现的顺序符合 DFS 森林上的<ruby>**先序**<rt>preorder</rt></ruby>,而 $G$ 的顶点被 DFS 搜完的顺序符合 DFS 森林上的<ruby>**后序**<rt>postorder</rt></ruby>。

![bg right:45% fit](pre_post.svg)

--- # DFS 与无向图的结构 对一个无向图做一次 DFS 可以获得关于这个图的许多结构信息。 --- # 例题 [abc318_g](https://atcoder.jp/contests/abc318/tasks/abc318_g) Typical Path Problem 给定一个连通的简单无向图 $G$。图 $G$ 有 $N$ 个点和 $M$ 条边。点从 $1$ 到 $N$ 编号。 给定图 $G$ 上三个不同的点 $a, b, c$。判断是否有从 $a$ 到 $c$ 且经过 $b$ 的简单路径。 ###### 限制 - $3 \leq N \leq 2\times 10^5$ - $N-1\leq M\leq\min\left(\frac{N(N-1)}{2},2\times 10^5\right)$ - $1\leq a, b, c \leq N$ - $a$,$b$,$c$ 两两不同。 --- # 思路 <div class=col73><div> 从点 $a$ 开始对图 $G$ 做一次 DFS,得到 DFS 树 $T$。 如果 $c$ 是 $b$ 的后代,那么从 $a$ 经 $b$ 到 $c$ 的简单路径。 如果 $c$ 不是 $b$ 的后代,设 $d$ 是 $b$ 和 $c$ 的最近公共祖先,情形如右上图。 如果子树 $b$ 里有回边到 $d$ 的父节点或更高的祖先,那么也有从 $a$ 经 $b$ 到 $c$ 的简单路径,情形如右下图。 如果子树 $b$ 里的回边最多到点 $d$,那么从 $a$ 到 $b$ 必定经过 $d$,从 $b$ 到 $c$ 也必定经过点 $d$。 </div><div> ![h:250](abcd_in_dfs_tree.svg) ![h:250](abcde_in_dfs_tree.svg) </div></div>

###### 限制

![h:300](bcc_tree_edges.svg) 设 $p, u, v \in T$ 且 $p$ 是 $u$ 的父节点而 $u$ 是 $v$ 的父节点。注意到 边 $uv$ 和边 $pu$ 属于同一个双连通分量 $\iff \low(v) \ge \dfn(u)$ - $G$ 的双连通分量的数量就是 DFS 树中满足 $\low(v) \ge \dfn(u)$ 的非根节点 $v$ 的数量,$u$ 是 $v$ 的父节点。 --- ![bg right:10% h:300](bccv_back_edge_and_tree_edge.svg) 注意到回边一定在某个环里。设边 $va$ 是回边,$a$ 是 $v$ 的祖先,而 $u$ 是 $v$ 的父节点,那么边 $va$ 和 $v$ 的父边 $vu$ 必在同一个双连通分量里。因此,我们只需要找出哪些**树边**属于同一个双连通分量。 ---