Loading

图论(长期)

图论

序言

图论是算法竞赛中的重中之重,一套合格的题必定有十分考验技巧与转变的图论题,因此,学习图论者得天下,而掌握图论技巧则能在考试中立于不败之地。

本文将介绍常见的图论算法,并给出一些典型题目,帮助读者理解。

ex:本文难度偏向省选难度,建议初学者多多练习,提高自己的图论知识水平。

图的定义

图(Graph)是由顶点和边组成的集合,其中顶点可以看作是图中的节点,边可以看作是连接两个节点的线。

图的表示

图的表示有多种方式,最常见的有两种:邻接矩阵和邻接表。

邻接矩阵

邻接矩阵是用一个二维数组来表示图的顶点之间的连接关系。如果顶点 \(i\) 和顶点 \(j\) 之间存在一条边,则邻接矩阵中第 \(i\) 行第 \(j\) 列的元素的值为 \(1\),否则为 \(0\)

例如,下图是一个有 \(5\) 个顶点的图,用邻接矩阵表示:

0 1 1 0 0
1 0 1 1 0
1 1 0 1 1
0 1 1 0 1
0 0 1 1 0

邻接表

邻接表是用一个数组来表示图的顶点和边。数组中的每个元素是一个链表,链表中的每个元素是一个邻接顶点。

例如,下图是一个有 \(5\) 个顶点的图,用邻接表表示:

[1, 2, 3]  // 顶点 0 的邻接顶点
[0, 3]     // 顶点 1 的邻接顶点
[0, 2, 4]  // 顶点 2 的邻接顶点
[1, 4]     // 顶点 3 的邻接顶点
[2, 3]     // 顶点 4 的邻接顶点

图的遍历

图的遍历是指从图的某一顶点出发,依次访问图中的所有顶点,并对每个顶点所做的操作。图的遍历有很多种方式,最常见的有深度优先搜索(DFS)和广度优先搜索(BFS)。

深度优先搜索

深度优先搜索(DFS)是一种从图的某一顶点出发,沿着图的邻接边进行搜索的算法。具体来说,从某一顶点 \(v\) 出发,首先访问 \(v\),然后依次访问 \(v\) 的所有邻接顶点,然后依次访问这些顶点的邻接顶点,直到访问完所有顶点。

例如,下图是一个有 \(5\) 个顶点的图,用邻接表表示:

[1, 2, 3]  // 顶点 0 的邻接顶点
[0, 3]     // 顶点 1 的邻接顶点
[0, 2, 4]  // 顶点 2 的邻接顶点
[1, 4]     // 顶点 3 的邻接顶点
[2, 3]     // 顶点 4 的邻接顶点
  1. 从顶点 0 出发,访问顶点 0。
  2. 依次访问顶点 0 的邻接顶点 1 和 2。
  3. 访问顶点 1,然后依次访问顶点 1 的邻接顶点 0 和 3。
  4. 访问顶点 2,然后依次访问顶点 2 的邻接顶点 0 和 4。
  5. 访问顶点 3,然后依次访问顶点 3 的邻接顶点 1 和 4。
  6. 访问顶点 4,然后依次访问顶点 4 的邻接顶点 2 和 3。

广度优先搜索

广度优先搜索(BFS)是一种从图的某一顶点出发,沿着图的邻接顶点进行搜索的算法。具体来说,从某一顶点 \(v\) 出发,首先访问 \(v\),然后依次访问 \(v\) 的所有邻接顶点,然后依次访问这些顶点的邻接顶点,直到访问完所有顶点。

例如,下图是一个有 \(5\) 个顶点的图,用邻接表表示:

[1, 2, 3]  // 顶点 0 的邻接顶点
[0, 3]     // 顶点 1 的邻接顶点
[0, 2, 4]  // 顶点 2 的邻接顶点
[1, 4]     // 顶点 3 的邻接顶点
[2, 3]     // 顶点 4 的邻接顶点
  1. 从顶点 0 出发,访问顶点 0。
  2. 依次访问顶点 0 的邻接顶点 1 和 2。
  3. 访问顶点 1,然后依次访问顶点 1 的邻接顶点 0 和 3。
  4. 访问顶点 2,然后依次访问顶点 2 的邻接顶点 0 和 4。
  5. 访问顶点 3,然后依次访问顶点 3 的邻接顶点 1 和 4。
  6. 访问顶点 4,然后依次访问顶点 4 的邻接顶点 2 和 3。

图论基本算法

图的算法是指对图进行操作的一些常用算法。

最小生成树

最小生成树(Minimum Spanning Tree,MST)是指在一个无向图中,找出连接所有顶点的边所构成的树,使得树上所有边的权值和最小。

前置知识:生成树,生成子图。

常见处理方法有 Prim 算法和 Kruskal 算法。

kruskal 算法

Kruskal 算法是一种贪心算法,它每次选择一条权值最小的边,并将其加入生成树中,直到生成树中包含了所有顶点。

具体步骤如下:

  1. 按权值从小到大排序所有的边。
  2. 选择一条权值最小的边,并将其加入生成树中。
  3. 如果加入的边连接的两个顶点不在同一个连通分量中,则将这两个顶点所在的连通分量合并。
  4. 重复步骤 2 和 3,直到生成树中包含了所有顶点。

模板如下:

#include<bits/stdc++.h>
using namespace std;
#define re register
#define il inline
il int read(){
    re int x=0,f=1;char c=getchar();
    while(c<'0'||c>'9'){if(c=='-') f=-1;c=getchar();}
    while(c>='0'&&c<='9') x=(x<<3)+(x<<1)+(c^48),c=getchar();
    return x*f;
}
struct Edge{
	int u,v,w;
}edge[200005];
int fa[5005],n,m,ans,eu,ev,cnt;
il bool cmp(Edge a,Edge b){
    return a.w<b.w;
}
il int find(int x){
    while(x!=fa[x]) x=fa[x]=fa[fa[x]];
    return x;
}
il void kruskal(){
    sort(edge,edge+m,cmp);
    for(re int i=0;i<m;i++){
        eu=find(edge[i].u), ev=find(edge[i].v);
        if(eu==ev) continue;
        ans+=edge[i].w;
        fa[ev]=eu;
        if(++cnt==n-1) break;
    }
}
int main(){
    n=read(),m=read();
    for(re int i=1;i<=n;i++) fa[i]=i;
    for(re int i=0;i<m;i++){
        edge[i].u=read(),edge[i].v=read(),edge[i].w=read();
    }
    kruskal();
    printf("%d",ans);
    return 0;
}

prim 算法

Prim 算法是一种贪心算法,它每次选择一条权值最小的边,并将其加入生成树中,直到生成树中包含了所有顶点。

具体步骤如下:

  1. 选择一个顶点 \(v\),并将其加入生成树中。
  2. 对于 \(v\) 的所有邻接顶点 \(u\),如果 \((u,v)\) 存在权值最小的边,则将 \((u,v)\) 加入生成树中。
  3. 重复步骤 2,直到生成树中包含了所有顶点。

其实跟 Dijkstra 算法一样,每次找到距离最小的一个点,可以暴力找也可以用堆维护。

堆优化的方式类似 Dijkstra 的堆优化,但如果使用二叉堆等不支持 \(O(1)\) decrease-key 的堆,复杂度就不优于 Kruskal,常数也比 Kruskal 大。所以,一般情况下都使用 Kruskal 算法,在稠密图尤其是完全图上,暴力 Prim 的复杂度比 Kruskal 优,但 不一定 实际跑得更快。

暴力:\(O(n^2+m)\)

二叉堆:\(O((n+m) \log n)\)

Fib 堆:\(O(n \log n + m)\)

模板如下:

#include <cstring>
#include <iostream>
#include <queue>
using namespace std;
const int N = 5050, M = 2e5 + 10;

struct E {
  int v, w, x;
} e[M * 2];

int n, m, h[N], cnte;

void adde(int u, int v, int w) { e[++cnte] = E{v, w, h[u]}, h[u] = cnte; }

struct S {
  int u, d;
};

bool operator<(const S &x, const S &y) { return x.d > y.d; }

priority_queue<S> q;
int dis[N];
bool vis[N];

int res = 0, cnt = 0;

void Prim() {
  memset(dis, 0x3f, sizeof(dis));
  dis[1] = 0;
  q.push({1, 0});
  while (!q.empty()) {
    if (cnt >= n) break;
    int u = q.top().u, d = q.top().d;
    q.pop();
    if (vis[u]) continue;
    vis[u] = 1;
    ++cnt;
    res += d;
    for (int i = h[u]; i; i = e[i].x) {
      int v = e[i].v, w = e[i].w;
      if (w < dis[v]) {
        dis[v] = w, q.push({v, w});
      }
    }
  }
}

int main() {
  cin >> n >> m;
  for (int i = 1, u, v, w; i <= m; ++i) {
    cin >> u >> v >> w, adde(u, v, w), adde(v, u, w);
  }
  Prim();
  if (cnt == n)
    cout << res;
  else
    cout << "No MST.";
  return 0;
}

最短路

最短路径(Shortest Path)是指在一个有向图中,从一个顶点到另一个顶点的最短路径。

常用算法有 floyd 算法、 Dijkstra 算法以及已经死了(魔法意义上)的 SPFA 算法。

floyd 算法

floyd 算法是一种动态规划算法,它求解任意两点之间的最短路径。

具体步骤如下:

  1. 初始化 \(dp[i][j]\)\(i\)\(j\) 的最短路径长度,其中 \(i\)\(j\) 均属于 \(1\)\(n\)\(dp[i][i]\) 置为 \(0\)
  2. 对于 \(k\) 属于 \(1\)\(n\),对于 \(i\) 属于 \(1\)\(n\),对于 \(j\) 属于 \(1\)\(n\),若 \(dp[i][k]+dp[k][j]<dp[i][j]\),则 \(dp[i][j]=dp[i][k]+dp[k][j]\)
  3. 输出 \(dp[i][j]\) 即可。

模板如下:

#include<bits/stdc++.h>
using namespace std;
#define re register
#define il inline
il int read(){
    re int x=0,f=1;char c=getchar();
    while(c<'0'||c>'9'){if(c=='-') f=-1;c=getchar();}
    while(c>='0'&&c<='9') x=(x<<3)+(x<<1)+(c^48),c=getchar();
    return x*f;
}
const int N=5005;
int n,m,dp[N][N],ans[N][N];
int main(){
    n=read(),m=read();
    for(re int i=1;i<=n;i++) for(re int j=1;j<=n;j++) dp[i][j]=ans[i][j]=0x3f3f3f3f;
    for(re int i=1;i<=n;i++) dp[i][i]=0;
    for(re int i=1;i<=m;i++){
        int u=read(),v=read(),w=read();
        dp[u][v]=min(dp[u][v],w);
    }
    for(re int k=1;k<=n;k++)
        for(re int i=1;i<=n;i++)
            for(re int j=1;j<=n;j++)
                dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]);
    for(re int i=1;i<=n;i++)
        for(re int j=1;j<=n;j++)
            ans[i][j]=dp[i][j];
    for(re int i=1;i<=n;i++)
        for(re int j=1;j<=n;j++)
            printf("%d ",ans[i][j]);
    return 0;
}

Dijkstra 算法

Dijkstra 算法是一种贪心算法,它求解单源最短路径。

具体步骤如下:

  1. 初始化 \(dist[i]\)\(i\) 到源点的最短路径长度,其中 \(i\) 属于 \(1\)\(n\)\(dist[i]\) 置为 \(0\)
  2. 对于 \(i\) 属于 \(1\)\(n\),对于 \(j\) 属于 \(1\)\(n\),若 \((i,j)\) 存在边,且 \(dist[i]+w(i,j)<dist[j]\),则 \(dist[j]=dist[i]+w(i,j)\)
  3. 重复步骤 2,直到所有顶点都被访问。

有多种方法来维护 1 操作中最短路长度最小的结点,不同的实现导致了 Dijkstra 算法时间复杂度上的差异。

  1. 暴力:不使用任何数据结构进行维护,每次 2 操作执行完毕后,直接在 T 集合中暴力寻找最短路长度最小的结点。2 操作总时间复杂度为 \(O(m)\),1 操作总时间复杂度为 \(O(n^2)\),全过程的时间复杂度为 \(O(n^2 + m) = O(n^2)\)

  2. 二叉堆:每成功松弛一条边 \((u,v)\),就将 \(v\) 插入二叉堆中(如果 \(v\) 已经在二叉堆中,直接修改相应元素的权值即可),1 操作直接取堆顶结点即可。共计 \(O(m)\) 次二叉堆上的插入(修改)操作,\(O(n)\) 次删除堆顶操作,而插入(修改)和删除的时间复杂度均为 \(O(\log n)\),时间复杂度为 \(O((n+m) \log n) = O(m \log n)\)

  3. 优先队列:和二叉堆类似,但使用优先队列时,如果同一个点的最短路被更新多次,因为先前更新时插入的元素不能被删除,也不能被修改,只能留在优先队列中,故优先队列内的元素个数是 \(O(m)\) 的,时间复杂度为 \(O(m \log m)\)

  4. Fibonacci 堆:和前面二者类似,但 Fibonacci 堆插入的时间复杂度为 \(O(1)\),故时间复杂度为 \(O(n \log n + m)\),时间复杂度最优。但因为 Fibonacci 堆较二叉堆不易实现,效率优势也不够大1,算法竞赛中较少使用。

  5. 线段树:和二叉堆原理类似,不过将每次成功松弛后插入二叉堆的操作改为在线段树上执行单点修改,而 1 操作则是线段树上的全局查询最小值。时间复杂度为 \(O(m \log n)\)

在稀疏图中,\(m = O(n)\),使用二叉堆实现的 Dijkstra 算法较 Bellman–Ford 算法具有较大的效率优势;而在稠密图中,\(m = O(n^2)\),这时候使用暴力做法较二叉堆实现更优。

模板如下:

struct edge {
  int v, w;
};

vector<edge> e[maxn];
int dis[maxn], vis[maxn];

void Dijkstra(int n, int s) {
  memset(dis, 63, sizeof(dis));
  dis[s] = 0;
  for (int i = 1; i <= n; i++) {
    int u = 0, mind = 0x3f3f3f3f;
    for (int j = 1; j <= n; j++)
      if (!vis[j] && dis[j] < mind) u = j, mind = dis[j];
    vis[u] = true;
    for (auto ed : e[u]) {
      int v = ed.v, w = ed.w;
      if (dis[v] > dis[u] + w) dis[v] = dis[u] + w;
    }
  }
}

这是没有进行堆优化的算法,复杂度为 \(O(n^2)\)

堆优化步骤:

  1. 建立一个小顶堆,其中每个元素是一个顶点,堆中元素的权值是其到源点的最短路径长度。
  2. 对于堆中每个元素 \(u\),对于 \(v\) 属于 \(1\)\(n\),若 \((u,v)\) 存在边,且 \(dist[u]+w(u,v)<dist[v]\),则将 \((u,v)\) 加入堆中,并更新 \(dist[v]\)
  3. 重复步骤 2,直到堆为空。

模板如下:

struct edge {
  int v, w;
};

struct node {
  int dis, u;

  bool operator>(const node& a) const { return dis > a.dis; }
};

vector<edge> e[maxn];
int dis[maxn], vis[maxn];
priority_queue<node, vector<node>, greater<node> > q;

void Dijkstra(int n, int s) {
  memset(dis, 63, sizeof(dis));
  dis[s] = 0;
  q.push({0, s});
  while (!q.empty()) {
    int u = q.top().u;
    q.pop();
    if (vis[u]) continue;
    vis[u] = 1;
    for (auto ed : e[u]) {
      int v = ed.v, w = ed.w;
      if (dis[v] > dis[u] + w) {
        dis[v] = dis[u] + w;
        q.push({dis[v], v});
      }
    }
  }
}

SPFA 算法

SPFA 算法是一种队列优化的 Dijkstra 算法,它求解单源最短路径。

具体步骤如下:

  1. 初始化 \(dist[i]\)\(i\) 到源点的最短路径长度,其中 \(i\) 属于 \(1\)\(n\)\(dist[i]\) 置为 \(0\)
  2. 对于 \(i\) 属于 \(1\)\(n\),对于 \(j\) 属于 \(1\)\(n\),若 \((i,j)\) 存在边,且 \(dist[i]+w(i,j)<dist[j]\),则 \(dist[j]=dist[i]+w(i,j)\)
  3. 重复步骤 2,直到所有顶点都被访问。

但是很可惜的是, SPFA 算法虽然复杂度非常优秀,但是它会被菊花图卡了,所以一般不用,但是如果遇到负边权或者需要判负环的情况时,就必须要使用这个算法了。

struct edge {
  int v, w;
};

vector<edge> e[maxn];
int dis[maxn], cnt[maxn], vis[maxn];
queue<int> q;

bool spfa(int n, int s) {
  memset(dis, 63, sizeof(dis));
  dis[s] = 0, vis[s] = 1;
  q.push(s);
  while (!q.empty()) {
    int u = q.front();
    q.pop(), vis[u] = 0;
    for (auto ed : e[u]) {
      int v = ed.v, w = ed.w;
      if (dis[v] > dis[u] + w) {
        dis[v] = dis[u] + w;
        cnt[v] = cnt[u] + 1;
        if (cnt[v] >= n) return false;
        if (!vis[v]) q.push(v), vis[v] = 1;
      }
    }
  }
  return true;
}

Johnson 全源最短路算法

Johnson 全源最短路算法是一种对 Bellman-Ford 算法的改进,它可以解决有向图中存在负权边的最短路问题,同时可以处理任意两点之间的距离问题。

正常来说我们求解任意两点的最短路可以使用 floyd 算法,但是由于其复杂度过高,在数据规模大时就不行了。

可能会有读者想到可以给每条边加一个值使其为正,然后跑 \(n\) 次 Dijkstra 不就好了吗,时间复杂度 \(O(nm \log m)\),但其实这样是错误的。

\(1 \to 2\) 的最短路为 \(1 \to 5 \to 3 \to 2\),长度为 −2。

但假如我们把每条边的边权加上 5 呢?

新图上 \(1 \to 2\) 的最短路为 \(1 \to 4 \to 2\),已经不是实际的最短路了。

所以便有了 Johnson 算法。

具体步骤如下:

  1. 新建一个超级原点 0,将每个点与其相连,权值为 0。

  2. 用 SPFA 算出 0 号点与其他点的最短路记为 \(h_i\)

  3. \(u \to v\) 有边,将其边权改为 \(w+h_u-h_v\)

  4. 接下来以每个点为起点跑 Dijkstra 即可。

拓扑排序

拓扑排序(Topological Sort)是指对有向无环图(DAG)进行排序,使得每个顶点都在拓扑排序中出现在它的所有前驱顶点之后。

最形象化的表示就是,学高等数学,你需要先学初中数学,初中语文,再学高中数学,最后才能学高等数学。

拓扑序在 DAG 中有着广泛的应用,很多关于连通性问题都需要用到此原理。

Kahn 算法

Kahn 算法是一种基于队列的拓扑排序算法,它的时间复杂度为 \(O(n+m)\)

模板如下:

int n, m;
vector<int> G[MAXN];
int in[MAXN];

bool toposort() {
  vector<int> L;
  queue<int> S;
  for (int i = 1; i <= n; i++)
    if (in[i] == 0) S.push(i);
  while (!S.empty()) {
    int u = S.front();
    S.pop();
    L.push_back(u);
    for (auto v : G[u]) {
      if (--in[v] == 0) {
        S.push(v);
      }
    }
  }
  if (L.size() == n) {
    for (auto i : L) cout << i << ' ';
    return true;
  } else {
    return false;
  }
}

图的连通性问题

图的连通性问题有 5 种相关的问题。

  1. 强连通分量
  2. 双连通分量
  3. 割点和桥
  4. 圆方树
  5. 点/边连通度

本文将会介绍前 2 种问题。

在此之前,我们先研究一个欧拉图问题

欧拉图

  • 欧拉回路:通过图中每条边恰好一次的回路
  • 欧拉通路:通过图中每条边恰好一次的通路
  • 欧拉图:具有欧拉回路的图
  • 半欧拉图:具有欧拉通路但不具有欧拉回路的图

判别法

  1. 无向图是欧拉图当且仅当:
  • 非零度顶点是连通的
  • 顶点的度数都是偶数
  1. 无向图是半欧拉图当且仅当:
  • 非零度顶点是连通的
  • 恰有 2 个奇度顶点
  1. 有向图是欧拉图当且仅当:
  • 非零度顶点是强连通的
  • 每个顶点的入度和出度相等
  1. 有向图是半欧拉图当且仅当:
  • 非零度顶点是弱连通的
  • 至多一个顶点的出度与入度之差为 1
  • 至多一个顶点的入度与出度之差为 1
  • 其他顶点的入度和出度相等

欧拉图是连通性问题中的一个典型例子。

强连通分量

强连通的定义是:有向图 G 强连通是指,G 中任意两个结点连通。

强连通分量(Strongly Connected Components,SCC)的定义是:极大的强连通子图。

Tarjan 算法

  • 四条边:

树枝边:DFS时经过的边,即DFS搜索树上的边。

前向边:与DFS方向一致,从某个结点指向其某个子孙的边。

后向边:与DFS方向相反,从某个结点指向其某个祖先的边。(返祖边)

横叉边:从某个结点指向搜索树中的另一子树中的某结点的边。

Tarjan算法是基于对图深度优先搜索的算法,每个强连通分量为搜索树中的一棵子树。搜索时,把当前搜索树中未处理的节点加入一个堆栈,回溯时可以判断栈顶到栈中的节点是否为一个强连通分量。

定义 DFN(u) 为节点 \(u\) 搜索的次序编号(时间戳),Low(u) 为 \(u\)\(u\) 的子树能够追溯到的最早的栈中节点的次序号。

由定义可以得出,Low(u)=Min {Low(u), Low(v) }\((u,v)\) 为树枝边,\(u\)\(v\) 的父节点, Low(u)=Min {Low(u), DFN(v) },\((u,v)\) 为指向栈中节点的后向边(指向栈中结点的横叉边) 当结点 \(u\) 搜索结束后,若 DFN(u)=Low(u) 时,则以 \(u\) 为根的搜索子树上所有还在栈中的节点是一个强连通分量。

其实可以理解为:

Low[u] 表示节点 \(u\) 可以连的点中,最早出现的那一个点的 DFN 值。
因此当我们回溯到一个点 Low[u]=dfn[u] 时,代表这个点之后在栈中的点都是属于同一个连通块的,此时可以退栈直到 st[top]==u 时,此时栈顶元素就可以视为这个连通块的根节点(也可以新开节点,根据个人喜好可以略作修改)。

const int Max=1e5+10,N=1e4+10;
int n,m;
struct edge{
    int to,nxt,u;
}e[Max<<1];
int head[N],cnt=0;
void add(int u,int v){
    e[++cnt].nxt=head[u];
    e[cnt].to=v;
    e[cnt].u=u;
    head[u]=cnt;
}
int dfx[N],low[N],st[N],tp,scc[N],sccsize[N],num=0,col=0;
vector<int> ans[N];
void tarjan(int u){
    dfx[u]=low[u]=++num;
    st[++tp]=u;
    for(int i=head[u];i;i=e[i].nxt){
        int to=e[i].to;
        if(!dfx[to]){
            tarjan(to);
            low[u]=min(low[u],low[to]);
        }
        else if(!scc[to]){
            low[u]=min(low[u],dfx[to]);
        }
    }
    if(low[u]==dfx[u]){
        col++;
        ++sccsize[col];
        ans[col].push_back(u);
        while(st[tp]!=u){
            ++sccsize[col];
            ans[col].push_back(st[tp]);
            scc[st[tp]]=col;
            --tp;
        }
        --tp;
        scc[u]=col;
    }
}

运用 Tarjan 算法可以求出图的强连通分量,并记录每个分量的大小。这样也能处理出更多问题,比如缩点割点求桥问题,都是在此基础上拓展出来的。

缩点其实就是在退栈时将当前点加入新缩点就ok了。

const int Max=1e5+10,N=1e4+10;
int n,m;
struct edge{
    int to,nxt,u;
}e[Max<<1],ed[Max<<1];
int head[N],cnt=0,h[N],s=0;
void add(int u,int v){
    e[++cnt].nxt=head[u];
    e[cnt].to=v;
    e[cnt].u=u;
    head[u]=cnt;
}
int dfx[N],low[N],st[N],tp,scc[N],num=0,col=0;
int a[N];
bool vis[N];
void tarjan(int u){
    dfx[u]=low[u]=++num;
    st[++tp]=u;vis[u]=1;
    for(int i=head[u];i;i=e[i].nxt){
        int to=e[i].to;
        if(!dfx[to]){
            tarjan(to);
            low[u]=min(low[u],low[to]);
        }
        else if(vis[to]){
            low[u]=min(low[u],dfx[to]);
        }
    }
    if(low[u]==dfx[u]){
        col++;
        scc[u]=u;
        while(st[tp]!=u){
            scc[st[tp]]=u;
            a[u]+=a[st[tp]];
            vis[st[tp]]=0;
            --tp;
        }
        vis[st[tp]]=0;
        --tp;
    }
}

双连通分量

我们知道强连通分量是在有向图中讨论的,但如果是无向图呢,我们发现此时强连通对它没有意义,因为它不考虑方向。

双连通分量(Biconnected Component,BCC)的定义是:图中任意两个顶点都有路径相连。

双连通分点双连通和边双连通。

点双连通分量

#include<cstdio>
#include<cctype>
#include<vector>
using namespace std;
struct edge
{
    int to,pre;
}edges[1000001];
int head[1000001],dfn[1000001],dfs_clock,tot;
int num;//BCC数量 
int stack[1000001],top;//栈 
vector<int>bcc[1000001];
int tarjan(int u,int fa)
{
    int lowu=dfn[u]=++dfs_clock;
    for(int i=head[u];i;i=edges[i].pre)
        if(!dfn[edges[i].to])
        {
            stack[++top]=edges[i].to;//搜索到的点入栈 
            int lowv=tarjan(edges[i].to,u);
            lowu=min(lowu,lowv);
            if(lowv>=dfn[u])//是割点或根 
            {
                num++;
                while(stack[top]!=edges[i].to)//将点出栈直到目标点 
                    bcc[num].push_back(stack[top--]);
                bcc[num].push_back(stack[top--]);//目标点出栈 
                bcc[num].push_back(u);//不要忘了将当前点存入bcc 
            }
        }
        else if(edges[i].to!=fa)
            lowu=min(lowu,dfn[edges[i].to]);
    return lowu;
}
void add(int x,int y)//邻接表存边 
{
    edges[++tot].to=y;
    edges[tot].pre=head[x];
    head[x]=tot;
}
int main()
{
    int n,m;
    scanf("%d%d",&n,&m);
    for(int i=1;i<=m;i++)
    {
        int x,y;
        scanf("%d%d",&x,&y);
        add(x,y),add(y,x);
    }
    for(int i=1;i<=n;i++)//遍历n个点tarjan 
        if(!dfn[i])
        {
            stack[top=1]=i;
            tarjan(i,i);
        }
    for(int i=1;i<=num;i++)
    {
        printf("BCC#%d: ",i);
        for(int j=0;j<bcc[i].size();j++)
            printf("%d ",bcc[i][j]);
        printf("\n");
    }
    return 0;
}

边双连通分量

这个我认为比单双还简单,就直接在scc的基础上改了一下。可以说是很简洁了。

#include<bits/stdc++.h>
#define int long long
#define ull unsigned long long
#define PII pair<int,int>
#define mk(a,b) make_pair(a,b)
using namespace std;
template<typename P>
inline void read(P &x){
    P res=0,f=1;
    char ch=getchar();
    while(ch<'0' || ch>'9'){
        if(ch=='-') f=-1;
        ch=getchar();
    }
    while(ch>='0' && ch<='9'){
        res=res*10+ch-'0';
        ch=getchar();
    }
    x=res*f;
}
int T=1;
const int Max=2e6+10,N=5e5+10;
int n,m;
struct edge{
    int to,nxt,u,flag;
}e[Max<<1],ed[Max<<1];
int head[N],cnt=0,h[N],s=0;
void add(int u,int v,int flag){
    e[++cnt].nxt=head[u];
    e[cnt].to=v;
    e[cnt].u=u;
    e[cnt].flag=flag;
    head[u]=cnt;
}
int dfx[N],low[N],st[N],tp,scc[N],num=0,col=0;
int las;
vector<int> ans[N];
void tarjan(int u,int las){
    dfx[u]=low[u]=++num;
    st[++tp]=u;
    for(int i=head[u];i;i=e[i].nxt){
        int to=e[i].to;
        if(e[i].flag==(las^1)) continue;
        if(!dfx[to]){
            tarjan(to,e[i].flag);
            low[u]=min(low[u],low[to]);
        }
        else low[u]=min(low[u],dfx[to]);
    }
    if(low[u]==dfx[u]){
        col++;
        ans[col].push_back(u);
        while(st[tp]!=u){
            ans[col].push_back(st[tp]);
            --tp;
        }
        --tp;
    }   
}
signed main(){
    read(n),read(m);   
    for(int i=1;i<=m;++i){
        int u,v;
        read(u),read(v);
        add(u,v,i<<1);
        add(v,u,i<<1|1);
    }
    for(int i=1;i<=n;++i) if(!dfx[i]) tarjan(i,0);
    cout<<col<<endl;
    for(int i=1;i<=col;++i){
        cout<<ans[i].size()<<' ';
        for(auto it:ans[i]) cout<<it<<' ';
        cout<<endl;
    }
    return 0;
}

然后你就会发现,强连通、边双点双代码可以说是非常相似。

所以这里做一些区别。

双连通的区别

一、边双连通分量定义

在分量内的任意两个点总可以找到两条边不相同的路径互相到达。总而言之就是一个圈,正着走反着走都可以相互到达,至少只有一个点。

二、点双连通分量的定义

参照上面,唯一的不同:任意两个点可以找到一条点不同的路径互相到达。也是一个圈,正反走都可以,至少为一个点。

三、边、点双连通分量模板代码要注意的地方

  • 边双连通分量

1.每个节点的所有儿子遍历后才开始计算分量大小,请与点双连通相区分;

2.割顶只能属于一个分量,请与割边区分;(容易搞混)

3.要注意j是否是i的父节点;

  • 点双连通分量

1.每遍历一个儿子就计算是否有点连通分量;

2.割顶可以属于多个连通分量,请注意与割边区分;

3.当i为根节点时,至少要有两个儿子才能是割点;

强连通分量

模板注意的地方

1.每个点所有儿子遍历完才开始求分量;(类似边双连通分量)

2.每个点只能属于一个分量;

强连通分量和双连通分量常见的模型和问法

双连通分量

给出的图是非连通图,如:

  1. 有一些点,一些边,加最少的边,要使得整个图变成双联通图。

大致方法:求出所有分量,把每个分量看成一个点,统计每个点的度,有一个度为一则cnt加1,答案为(cnt+1)/2;

  1. 有一些点,一些边,问最少多少个点单着。

大致方法:求出所有的分量即可,但要注意不同的题可能有特殊要求(如圆桌骑士要求奇圈,要用到二分图判定)

  1. 各种变式问题

给出的图是连通图,如:

  1. 给定一个起点一个终点,求各种问题是否能实现。

大致方法:求出所有分量,并把每个分量当成点,于是问题得到化简;

  1. 给一个图,然后有大量的离线回答。

大致方法:求出所有分量,再求出上下子树的信息;

  1. 各种变式问题;

强连通分量

给出的是非连通图,如:

  1. 有一些点,一些有向边,求至少加多少边使任意两个点可相互到达

大致方法:求出所有的分量,缩点,分别求出出度入度为0的点的数量,取多的为答案;

  1. 有一些点,一些有向边,求在这个图上走一条路最多可以经过多少个点

大致方法:求出所有的分量,缩点,形成一个或多个DAG图,然后做DAG上的dp

  1. 有一些点,一些有向边,给出一些特殊点,求终点是特殊点的最长的一条路

大致方法:求出所有分量,并标记哪些分量有特殊点,然后也是DAG的dp

给出的是连通图,比较少,有也比较简单

总结

  1. 遇到非连通图几乎可以肯定是要求连通分量,不论是无向还是有向图;(可以节约大量思考时间)

  2. 凡是对边、点的操作,在同一个分量内任意一个点效果相同的,几乎都是缩点解决问题;再粗暴点,几乎求了连通分量都要缩点;

  3. 一定要考虑特殊情况,如整个图是一个连通分量等(考虑到了就有10-20分);

  4. 对于双连通分量要分析是边还是点双连通分量;

  5. 拿到题目要先搞清楚给的是连通图还是非连通图。

posted @ 2024-08-02 12:04  God_Max_Me  阅读(30)  评论(0编辑  收藏  举报