图论详解

图论详解

本文系统讲解图论连通性相关算法与变形,包含 Tarjan 系列算法、DFS 树、割点、桥、点双连通分量、边双连通分量、强连通分量、缩点、圆方树、2-SAT 等内容。
代码统一使用 vector 邻接表存图,不使用动态指针,不使用链式前向星。


目录

  1. 基本概念与符号
  2. DFS 生成树与时间戳
  3. 无向图割点与割边(桥)
  4. 无向图边双连通分量(e-DCC)
  5. 无向图点双连通分量(v-DCC)
  6. 有向图强连通分量(SCC)
  7. 缩点与 DAG 应用
  8. 有向图弱连通、单向连通、强连通判定
  9. 2-SAT 与 SCC 变形
  10. 圆方树(Block-Cut Tree)
  11. 综合应用与经典变形
  12. 复杂度总结与模板整合
  13. 常见错误与调试技巧

第1章 基本概念与符号

1.1 图的定义

\(G=(V,E)\),其中 $ V $ 是顶点集合,\(E\) 是边集合。

  • 无向图:边 \(e=(u,v)\) 是无序对,表示 \(u\)\(v\) 互相连接。
  • 有向图:边 \(e=(u,v)\) 是有序对,表示存在从 \(u\)\(v\) 的有向边。
  • :图的顶点数,通常记为 \(n=|V|\)
  • 边数:图的边数,通常记为 \(m=|E|\)
  • 简单图:无重边、无自环的图。
  • 重边:两个顶点之间有多条边。
  • 自环:一条边连接一个顶点到它自身。

在竞赛代码中,我们通常用 \(n,m\) 表示顶点数和边数。

1.2 邻接表存图

无向图存储

对于无向图,每条边存两次:

#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    vector<vector<int>> g(n+1);
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    return 0;
}

有向图存储

有向图只存一次:

vector<vector<int>> g(n+1);
for(int i=1;i<=m;i++){
    int u,v;
    cin>>u>>v;
    g[u].push_back(v);
}

如果题目需要处理重边或需要知道边的编号,可以存 pair<int,int> 表示 {邻接点, 边编号}

vector<vector<pair<int,int>>> g(n+1);
for(int i=1;i<=m;i++){
    int u,v;
    cin>>u>>v;
    g[u].push_back({v,i});
    g[v].push_back({u,i}); // 无向图
}

第2章 DFS 生成树与时间戳

2.1 深度优先搜索树

对无向连通图从任意一点出发进行 DFS,每个点第一次被访问时,从父亲到该点的边会形成一棵树,称为 DFS 生成树

对于有向图,DFS 同样会形成一棵生成森林(如果图不连通)。

在 DFS 过程中,我们可以对边进行分类:

  • 树边:DFS 树中的边,由父亲指向儿子。
  • 返祖边(回边):无向图中,指向 DFS 树上祖先的非树边。
  • 前向边:有向图中,由祖先指向后代的非树边。
  • 交叉边:有向图中,连接两个无祖先后代关系的节点的边。

无向图有一个重要性质:无向图的非树边全是返祖边,不存在交叉边。这是因为无向图 DFS 时,如果访问到一个已经访问过的节点,该节点一定是当前节点的祖先。

2.2 时间戳 dfn 与 low

dfn[u]

\(dfn[u]\) 表示 DFS 第一次访问顶点 \(u\) 的时间戳,即访问顺序编号。

low[u]

\(low[u]\) 表示顶点 \(u\) 及其后代通过至多一条返祖边能到达的最小 \(dfn\) 值。

更形式化地:

\[low[u] = \min \begin{cases} dfn[u],\\ dfn[w], & \text{存在返祖边 } (u,w),\\ low[v], & v \text{ 是 } u \text{ 的儿子} \end{cases} \]

在 Tarjan 算法中,low 值的正确维护是核心。

2.3 代码框架

vector<vector<int>> g;
vector<int> dfn,low;
int timer;

void dfs(int u,int fa){
    dfn[u]=low[u]=++timer;
    for(int v:g[u]){
        if(v==fa) continue;
        if(!dfn[v]){
            dfs(v,u);
            low[u]=min(low[u],low[v]);
        }
        else{
            low[u]=min(low[u],dfn[v]);
        }
    }
}

在无向图中,必须处理父亲节点,避免重复走回父亲导致 low 被错误更新。如果存在重边,仅用父亲节点判断不够,需要记录边编号。


第3章 无向图割点与割边(桥)

3.1 割点定义

割点(cut vertex / articulation point):删除该顶点及其所有关联边后,图的连通分量数量增加。

形式化:若原图连通,删除顶点 \(u\) 后图不再连通,则 \(u\) 是割点。对于不连通图,只要在某一连通块中删除后该连通块分裂,则 \(u\) 是割点。

3.2 桥定义

桥(bridge / cut edge):删除该边后,图的连通分量数量增加。

若原图连通,删除边 \(e\) 后图不再连通,则 \(e\) 是桥。

3.3 定理与判定条件

定理 3.1(割点判定定理)

在无向图的 DFS 树中:

  • 非根节点 \(u\) 是割点,当且仅当存在一个 DFS 树上的儿子 \(v\),满足:

\[low[v] \ge dfn[u] \]

  • 根节点 \(u\) 是割点,当且仅当它在 DFS 树上有至少两个儿子。

定理 3.2(桥判定定理)

在无向图的 DFS 树中,树边 \((u,v)\)\(u\) 是父亲,\(v\) 是儿子)是桥,当且仅当:

\[low[v] > dfn[u] \]

证明思路

\(low[v] \ge dfn[u]\),说明 \(v\) 及其后代无法通过返祖边回到 \(u\) 的祖先,因此删除 \(u\) 后,\(v\) 所在子树与 \(u\) 的祖先部分断开,故 \(u\) 是割点。

\(low[v] > dfn[u]\),说明 \(v\) 及其后代无法回到 \(u\),因此边 \((u,v)\) 是连接两个连通块的唯一路径,删除后图分裂,故是桥。

3.4 算法实现

3.4.1 无重边版本

#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

vector<vector<int>> g;
vector<int> dfn,low;
int timer;
vector<int> cut;
vector<pair<int,int>> bridges;

void dfs(int u,int fa){
    dfn[u]=low[u]=++timer;
    int child=0;
    for(int v:g[u]){
        if(v==fa) continue;
        if(!dfn[v]){
            child++;
            dfs(v,u);
            low[u]=min(low[u],low[v]);
            if(low[v]>=dfn[u]&&fa!=0) cut[u]=1;
            if(low[v]>dfn[u]) bridges.push_back({u,v});
        }
        else{
            low[u]=min(low[u],dfn[v]);
        }
    }
    if(fa==0&&child>1) cut[u]=1;
}

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    g.assign(n+1,{});
    dfn.assign(n+1,0);
    low.assign(n+1,0);
    cut.assign(n+1,0);
    timer=0;
    for(int i=1;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);
    }
    vector<int> ans;
    for(int i=1;i<=n;i++) if(cut[i]) ans.push_back(i);
    cout<<ans.size()<<"\n";
    for(int x:ans) cout<<x<<" ";
    cout<<"\n";
    cout<<bridges.size()<<"\n";
    for(auto e:bridges) cout<<e.fi<<" "<<e.se<<"\n";
    return 0;
}

3.4.2 有重边版本

如果图存在重边,仅用 fa 判断会出错。需要记录边编号,避免走回同一条边。

#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

vector<vector<pair<int,int>>> g; // {v, edge_id}
vector<int> dfn,low;
int timer;
vector<int> cut;
vector<int> is_bridge;

void dfs(int u,int pre_id){
    dfn[u]=low[u]=++timer;
    int child=0;
    for(auto e:g[u]){
        int v=e.fi,id=e.se;
        if(id==pre_id) continue;
        if(!dfn[v]){
            child++;
            dfs(v,id);
            low[u]=min(low[u],low[v]);
            if(low[v]>=dfn[u]&&pre_id!=0) cut[u]=1;
            if(low[v]>dfn[u]) is_bridge[id]=1;
        }
        else{
            low[u]=min(low[u],dfn[v]);
        }
    }
    if(pre_id==0&&child>1) cut[u]=1;
}

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    g.assign(n+1,{});
    dfn.assign(n+1,0);
    low.assign(n+1,0);
    cut.assign(n+1,0);
    is_bridge.assign(m+1,0);
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back({v,i});
        g[v].push_back({u,i});
    }
    timer=0;
    for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i,0);
    vector<int> ans;
    for(int i=1;i<=n;i++) if(cut[i]) ans.push_back(i);
    cout<<ans.size()<<"\n";
    for(int x:ans) cout<<x<<" ";
    cout<<"\n";
    vector<int> br;
    for(int i=1;i<=m;i++) if(is_bridge[i]) br.push_back(i);
    cout<<br.size()<<"\n";
    for(int x:br) cout<<x<<" ";
    cout<<"\n";
    return 0;
}

3.5 割点与桥的变形

变形1:求删除每个点后的连通块数量

设原图连通块数为 \(C\),删除点 \(u\) 后连通块数变为 \(C-1+child\),其中 \(child\) 是 DFS 树中满足 \(low[v]\ge dfn[u]\) 的儿子数量(若 \(u\) 是根则用 \(child\) 数量,若根且 \(child=0\) 则为0)。更具体地:

  • 非根节点 \(u\):删除后连通块数增加 \(\text{满足 } low[v]\ge dfn[u]\text{ 的儿子数}\)
  • 根节点 \(u\):删除后连通块数增加 \(\max(0, child-1)\)

变形2:求桥的编号并输出连接的点

使用有重边版本,记录边编号即可。

变形3:割边的在线维护

一般竞赛不要求,但可使用 LCT 或并查集缩边双动态维护,复杂度较高。


第4章 无向图边双连通分量(e-DCC)

4.1 定义

边双连通性:无向图中,如果任意两点之间存在两条边不相交的路径,则称该图是边双连通的。

等价定义:图中不存在桥。

边双连通分量(Edge-Biconnected Component, e-DCC):极大的边双连通子图。删除图中所有桥后,每个连通块就是一个边双连通分量。

4.2 定理

定理 4.1

一个无向连通图是边双连通的,当且仅当图中没有桥。

定理 4.2

边双连通分量内部任意两点之间不存在必经边。

定理 4.3

将所有边双连通分量缩成一个点后,原图变成一棵树(如果原图连通),树边就是原来的桥。这棵树称为 桥树

4.3 算法一:删桥后 BFS/DFS

首先用 Tarjan 求出所有桥,然后标记这些桥,最后在忽略桥的图上进行 BFS/DFS,每个连通块就是一个 e-DCC。

#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

vector<vector<pair<int,int>>> g;
vector<int> dfn,low;
vector<int> is_bridge;
int timer;

void dfs(int u,int pre_id){
    dfn[u]=low[u]=++timer;
    for(auto e:g[u]){
        int v=e.fi,id=e.se;
        if(id==pre_id) continue;
        if(!dfn[v]){
            dfs(v,id);
            low[u]=min(low[u],low[v]);
            if(low[v]>dfn[u]) is_bridge[id]=1;
        }
        else{
            low[u]=min(low[u],dfn[v]);
        }
    }
}

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    g.assign(n+1,{});
    dfn.assign(n+1,0);
    low.assign(n+1,0);
    is_bridge.assign(m+1,0);
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back({v,i});
        g[v].push_back({u,i});
    }
    timer=0;
    for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i,0);
    vector<int> dcc(n+1,0);
    int cnt=0;
    for(int i=1;i<=n;i++){
        if(!dcc[i]){
            cnt++;
            queue<int> q;
            q.push(i);
            dcc[i]=cnt;
            while(!q.empty()){
                int u=q.front();q.pop();
                for(auto e:g[u]){
                    int v=e.fi,id=e.se;
                    if(is_bridge[id]) continue;
                    if(!dcc[v]){
                        dcc[v]=cnt;
                        q.push(v);
                    }
                }
            }
        }
    }
    cout<<cnt<<"\n";
    for(int i=1;i<=n;i++) cout<<dcc[i]<<" ";
    cout<<"\n";
    return 0;
}

4.4 算法二:Tarjan 栈染色

在 DFS 过程中,当回溯时若 \(low[v]>dfn[u]\) 则为桥,否则将 \(v\) 加入当前 e-DCC。此方法略复杂,删桥法更常用。

4.5 变形与应用

变形1:最少加多少条边使无向图变为边双连通

先缩 e-DCC 得到桥树。设桥树叶子节点数为 \(leaf\),则最少需要加:

\[\left\lceil \frac{leaf}{2} \right\rceil \]

条边。若原图已经是边双连通,则答案为0。

变形2:桥树上路径问题

缩点成桥树后,任意两点之间的桥的数量等于它们在桥树上的路径长度。

变形3:动态加边维护边双

离线逆序用并查集缩 e-DCC,此处不展开。


第5章 无向图点双连通分量(v-DCC)

5.1 定义

点双连通性:无向图中,如果任意两点之间存在两条内部点不相交的路径(除起点和终点外),则称该图是点双连通的。

等价定义:任意两条边同属于一个简单环;图中不存在割点。

点双连通分量(Vertex-Biconnected Component, v-DCC):极大的点双连通子图。

注意:

  • 一条边连接两个点,且这两个点只靠这条边连通,这两个点也构成一个点双。
  • 孤立点本身是一个点双。
  • 割点会同时属于多个点双分量。

5.2 定理

定理 5.1

点双连通分量之间至多共享一个点,即割点。

定理 5.2

每个非割点只属于一个 v-DCC,每个割点属于至少两个 v-DCC。

定理 5.3

把每个 v-DCC 看作一个方点,原图的每个顶点看作圆点,则每个割点连接它所属的所有 v-DCC,得到的图是一棵树,称为 圆方树(Block-Cut Tree)

5.3 算法原理

维护一个边栈。DFS 访问边时压栈。当判定 \(low[v]\ge dfn[u]\) 时,从栈顶弹出边直到弹出边 \((u,v)\),这些边的端点构成一个 v-DCC。

5.4 算法实现

#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

vector<vector<int>> g;
vector<int> dfn,low;
int timer;
vector<vector<int>> bcc;
vector<pair<int,int>> stk;

void dfs(int u,int fa){
    dfn[u]=low[u]=++timer;
    int child=0;
    for(int v:g[u]){
        if(v==fa) continue;
        if(!dfn[v]){
            stk.push_back({u,v});
            child++;
            dfs(v,u);
            low[u]=min(low[u],low[v]);
            if(low[v]>=dfn[u]){
                vector<int> comp;
                while(true){
                    auto e=stk.back();stk.pop_back();
                    int a=e.fi,b=e.se;
                    comp.push_back(a);
                    comp.push_back(b);
                    if(a==u&&b==v) break;
                }
                sort(comp.begin(),comp.end());
                comp.erase(unique(comp.begin(),comp.end()),comp.end());
                bcc.push_back(comp);
            }
        }
        else if(dfn[v]<dfn[u]){
            stk.push_back({u,v});
            low[u]=min(low[u],dfn[v]);
        }
    }
    if(fa==0&&child==0){
        bcc.push_back({u});
    }
}

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    g.assign(n+1,{});
    dfn.assign(n+1,0);
    low.assign(n+1,0);
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    timer=0;
    for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i,0);
    cout<<bcc.size()<<"\n";
    for(auto &c:bcc){
        cout<<c.size()<<" ";
        for(int x:c) cout<<x<<" ";
        cout<<"\n";
    }
    return 0;
}

5.5 圆方树构建

在求出所有 v-DCC 后,可以构建圆方树。

  • 圆点:原图的 \(n\) 个顶点,编号 \(1\sim n\)
  • 方点:每个 v-DCC 一个,编号 \(n+1\sim n+bcc.size()\)

建边:每个方点连接它包含的所有圆点。

int N=n+bcc.size();
vector<vector<int>> T(N+1);
for(int i=0;i<bcc.size();i++){
    int f=n+i+1;
    for(int x:bcc[i]){
        T[f].push_back(x);
        T[x].push_back(f);
    }
}

5.6 圆方树应用

  • 求两点路径上的必经点(割点):路径上的圆点就是必经点。
  • 求点双路径点权和:对圆方树做树上前缀和或 LCA。
  • 判断某点是否是两点间的必经点:等价于该点在圆方树上两点路径上。

第6章 有向图强连通分量(SCC)

6.1 定义

有向图中,若任意两个顶点 \(u,v\) 互相可达,则称该图是强连通的。

强连通分量(Strongly Connected Component, SCC):极大的强连通子图。

另有:

  • 弱连通:将有向边视为无向边后,图连通。
  • 单向连通:对任意两点 \(u,v\),存在 \(u\to v\)\(v\to u\) 的路径。

6.2 定理

定理 6.1

SCC 之间不存在环。缩点后得到有向无环图(DAG)。

定理 6.2

每个 SCC 是顶点集上的等价类。

定理 6.3

Kosaraju 算法和 Tarjan 算法均可在 \(O(n+m)\) 时间内求出所有 SCC。

6.3 Tarjan 求 SCC

维护 dfnlow,栈记录当前 DFS 路径上尚未确定 SCC 的节点。当 \(dfn[u]=low[u]\) 时弹出栈直到 \(u\),这些点构成一个 SCC。

#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

vector<vector<int>> g;
vector<int> dfn,low,belong,instk;
vector<int> stk;
int timer,scc_cnt;

void dfs(int u){
    dfn[u]=low[u]=++timer;
    stk.push_back(u);
    instk[u]=1;
    for(int v:g[u]){
        if(!dfn[v]){
            dfs(v);
            low[u]=min(low[u],low[v]);
        }
        else if(instk[v]){
            low[u]=min(low[u],dfn[v]);
        }
    }
    if(dfn[u]==low[u]){
        scc_cnt++;
        int v;
        do{
            v=stk.back();stk.pop_back();
            instk[v]=0;
            belong[v]=scc_cnt;
        }while(v!=u);
    }
}

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    g.assign(n+1,{});
    dfn.assign(n+1,0);
    low.assign(n+1,0);
    belong.assign(n+1,0);
    instk.assign(n+1,0);
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back(v);
    }
    timer=0;scc_cnt=0;
    for(int i=1;i<=n;i++) if(!dfn[i]) dfs(i);
    cout<<scc_cnt<<"\n";
    for(int i=1;i<=n;i++) cout<<belong[i]<<" ";
    cout<<"\n";
    return 0;
}

6.4 Kosaraju 算法

两次 DFS:

  1. 对原图做 DFS,记录点的完成时间顺序。
  2. 在反向图中按完成时间逆序做 DFS,每个 DFS 连通块是一个 SCC。
#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

vector<vector<int>> g,rg;
vector<int> order,comp,vis;

void dfs1(int u){
    vis[u]=1;
    for(int v:g[u]) if(!vis[v]) dfs1(v);
    order.push_back(u);
}

void dfs2(int u,int c){
    comp[u]=c;
    for(int v:rg[u]) if(!comp[v]) dfs2(v,c);
}

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    g.assign(n+1,{});
    rg.assign(n+1,{});
    vis.assign(n+1,0);
    comp.assign(n+1,0);
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back(v);
        rg[v].push_back(u);
    }
    for(int i=1;i<=n;i++) if(!vis[i]) dfs1(i);
    int cnt=0;
    for(int i=order.size()-1;i>=0;i--){
        int u=order[i];
        if(!comp[u]) dfs2(u,++cnt);
    }
    cout<<cnt<<"\n";
    for(int i=1;i<=n;i++) cout<<comp[i]<<" ";
    cout<<"\n";
    return 0;
}

6.5 Gabow 算法

Gabow 算法也是 \(O(n+m)\),使用两个栈,但实际竞赛中 Tarjan 和 Kosaraju 已足够。


第7章 缩点与 DAG 应用

7.1 缩点

将每个 SCC 缩成一个新点,建立新图。新图一定是有向无环图(DAG)。

vector<vector<int>> ng(scc_cnt+1);
vector<int> in(scc_cnt+1),out(scc_cnt+1);
for(int u=1;u<=n;u++){
    for(int v:g[u]){
        if(belong[u]!=belong[v]){
            ng[belong[u]].push_back(belong[v]);
            out[belong[u]]++;
            in[belong[v]]++;
        }
    }
}
// 去重
for(int i=1;i<=scc_cnt;i++){
    sort(ng[i].begin(),ng[i].end());
    ng[i].erase(unique(ng[i].begin(),ng[i].end()),ng[i].end());
}

7.2 DAG 上最长路径 / 路径计数

缩点后拓扑排序 + DP。

vector<int> topo;
queue<int> q;
for(int i=1;i<=scc_cnt;i++) if(in[i]==0) q.push(i);
while(!q.empty()){
    int u=q.front();q.pop();
    topo.push_back(u);
    for(int v:ng[u]){
        in[v]--;
        if(in[v]==0) q.push(v);
    }
}
vector<int> dp(scc_cnt+1);
for(int i=topo.size()-1;i>=0;i--){
    int u=topo[i];
    for(int v:ng[u]){
        dp[u]=max(dp[u],dp[v]+1);
    }
}

第8章 有向图弱连通、单向连通、强连通判定

8.1 弱连通

将有向边视为无向边,做 DFS/BFS 判断连通性。

8.2 强连通

SCC 数量是否为 1。

8.3 单向连通

先缩点得到 DAG。单向连通当且仅当 DAG 存在一条路径经过所有点。判定方法:

  • 入度为 0 的点唯一;
  • 拓扑排序过程中,任意时刻队列中最多一个点;
  • 且拓扑序中相邻两点在原 DAG 中有边。

若满足,则原图单向连通。


第9章 2-SAT 与 SCC 变形

9.1 2-SAT 问题

给定 \(n\) 个布尔变量 \(x_1,\ldots,x_n\),和 \(m\) 个子句,每个子句是两个文字的逻辑或,例如:

\[(x_i \lor \neg x_j) \]

要求判断是否存在一组赋值使所有子句为真。

9.2 建图

对于每个变量 \(x_i\),建立两个点:

  • \(i\) 表示 \(x_i=true\)
  • \(i+n\) 表示 \(x_i=false\)

每个子句 \((a\lor b)\) 添加两条蕴含边:

\[\neg a\to b,\quad \neg b\to a \]

其中 \(a,b\) 是文字。

9.3 SCC 判定

\(x_i\)\(\neg x_i\) 在同一个 SCC 中,则无解;否则有解。

赋值时,选择拓扑序较后的 SCC 对应的文字为真。

9.4 代码框架

#include <bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;

vector<vector<int>> g;
vector<int> dfn,low,belong,instk;
vector<int> stk;
int timer,scc_cnt;

void dfs(int u){
    dfn[u]=low[u]=++timer;
    stk.push_back(u);instk[u]=1;
    for(int v:g[u]){
        if(!dfn[v]){
            dfs(v);
            low[u]=min(low[u],low[v]);
        }
        else if(instk[v]) low[u]=min(low[u],dfn[v]);
    }
    if(dfn[u]==low[u]){
        scc_cnt++;
        int v;
        do{
            v=stk.back();stk.pop_back();
            instk[v]=0;belong[v]=scc_cnt;
        }while(v!=u);
    }
}

int id(int x,int val){
    return x+n*val;
}

signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n,m;
    cin>>n>>m;
    g.assign(2*n+1,{});
    dfn.assign(2*n+1,0);
    low.assign(2*n+1,0);
    belong.assign(2*n+1,0);
    instk.assign(2*n+1,0);
    for(int i=1;i<=m;i++){
        int a,va,b,vb;
        cin>>a>>va>>b>>vb;
        int na=a+n*(va^1);
        int nb=b+n*(vb^1);
        int pa=a+n*va;
        int pb=b+n*vb;
        g[na].push_back(pb);
        g[nb].push_back(pa);
    }
    for(int i=1;i<=2*n;i++) if(!dfn[i]) dfs(i);
    for(int i=1;i<=n;i++){
        if(belong[i]==belong[i+n]){
            cout<<"IMPOSSIBLE\n";
            return 0;
        }
    }
    cout<<"POSSIBLE\n";
    for(int i=1;i<=n;i++){
        cout<<(belong[i]>belong[i+n])<<" ";
    }
    return 0;
}

第10章 圆方树(Block-Cut Tree)

10.1 定义

圆方树是无向图点双连通分量的一种树形表示。

  • 圆点:原图顶点。
  • 方点:每个点双连通分量。
  • 边:每个方点与它包含的所有圆点连边。

圆方树是一棵树,常用于处理与割点、点双路径相关的问题。

10.2 构建

在前面求出 v-DCC 的基础上:

int N=n+bcc.size();
vector<vector<int>> T(N+1);
for(int i=0;i<bcc.size();i++){
    int f=n+i+1;
    for(int x:bcc[i]){
        T[f].push_back(x);
        T[x].push_back(f);
    }
}

10.3 应用

  • 两点间路径上的割点:圆方树上路径经过的圆点。
  • 点双路径点权和:树上差分或前缀和。
  • 判断必经点:点 \(c\)\(u,v\) 间的必经点当且仅当 \(c\) 是圆方树上 \(u,v\) 路径上的圆点。

第11章 综合应用与经典变形

11.1 割点 + 点双统计

问题:对于每个点,求删除它后图的连通块数。

先求割点,并统计每个割点的“分裂儿子数”。

vector<int> sz(n+1); // 分裂数
// 在 dfs 中:
if(low[v]>=dfn[u]){
    sz[u]++;
}
// 答案:原图连通块数 - 1 + sz[u]

11.2 桥 + 边双:最少加边使全图边双

缩 e-DCC 成桥树,叶子数 \(leaf\),答案 \(\lceil leaf/2\rceil\)

11.3 SCC 缩点 + 拓扑排序/DP

最长路径、路径计数、可达性查询。

11.4 2-SAT 建图

用于约束满足问题,如每对中选一个、有冲突等。

11.5 圆方树路径问题

处理无向图必经点、点双路径。

11.6 动态加边询问连通性

离线逆序并查集,配合 Tarjan 预处理。


第12章 复杂度总结与模板整合

算法 时间复杂度 空间复杂度 适用场景
Tarjan 割点 \(O(n+m)\) \(O(n+m)\) 无向图割点
Tarjan 桥 \(O(n+m)\) \(O(n+m)\) 无向图桥
e-DCC \(O(n+m)\) \(O(n+m)\) 无向图边双
v-DCC \(O(n+m)\) \(O(n+m)\) 无向图点双
Kosaraju SCC \(O(n+m)\) \(O(n+m)\) 有向图强连通
Tarjan SCC \(O(n+m)\) \(O(n+m)\) 有向图强连通
2-SAT \(O(n+m)\) \(O(n+m)\) 布尔约束
圆方树 \(O(n+m)\) \(O(n+m)\) 点双路径

第13章 常见错误与调试技巧

  1. 无向图未处理父亲节点:导致 low 被更新为父亲,误判割点或桥。
  2. 重边未记录边编号:导致桥漏判。
  3. 根节点割点判断错误:根节点需要至少两个儿子。
  4. SCC 栈标记未更新:弹栈后忘记 instk[v]=0
  5. 缩点后重边未去重:可能导致拓扑排序出度入度计算错误。
  6. 2-SAT 变量编号混乱:注意真假点的映射。
  7. #define int long longmain 未写 signed:导致编译错误。
  8. DFS 递归深度过大:可使用递归或手工栈;竞赛中通常无问题。
  9. low 更新时机错误:有向图中只有栈内节点才更新 low[u]
  10. v-DCC 边栈未清空:应在每次找到一个 v-DCC 后立即弹出相应边。
posted @ 2026-09-18 09:38  shenchen2011  阅读(10)  评论(0)    收藏  举报