学不会的图论——连通性篇

发布时间 2023-09-14 18:06:05作者: clear_tea

前言

今天是camp的第一天(因为几乎啥都不会写了好久,中途还休息了一天,所以发的时候已经是emmmm,开学了QAQ),刚开始还挺轻松的,说了一些背景和故事。鼓励的话差不多了,就开始进入正题,介绍了一些图论的基本概念,紧接着就是Dijkstra(放在另一个blog了),这部分还好接受啦,毕竟我也不是一点图论都不会。短暂休息了一下,开始讲tarjan,这不是我一直没填坑的“图上删边”的关键算法吗!听完之后感觉有点懵,但是又不知道哪里懵,没有完全理解QAQ。最后又休息了一会儿,然后快速过了一个tarjan的题目,开始2-sat,这个就几乎完全懵了╯︿╰。To my surprise,群友都说太简单了?。中午结束的时候,饿得几乎走不动了QAQ。

tarjan

强连通

在有向图G中,两个节点u和v互相可达,则称u和v是强连通的。如果G中任意两个节点互相可达,则称这张图是强连通图。

强连通分量

如果一个有向图G不是强连通图,那么可以把它分成多个子图,其中每个子图鱼的内部是强连通的,而且这些子图不能与子图外的任意点强连通,称这样的一个“极大强连通”子图是G的一个强连通分量(Strongly Connected Components,SCC)。

定理

一个SCC,从其中任意点出发,都至少有一条路径能绕回自己。

迷宫城堡

Problem Description

为了训练小希的方向感,Gardon建立了一座大城堡,里面有N个房间(N<=10000)和M条通道(M<=100000),每个通道都是单向的,就是说若称某通道连通了A房间和B房间,只说明可以通过这个通道由A房间到达B房间,但并不说明通过它可以由B房间到达A房间。Gardon需要请你写个程序确认一下是否任意两个房间都是相互连通的,即:对于任意的i和j,至少存在一条路径可以从房间i到房间j,也存在一条路径可以从房间j到房间i。

Input

输入包含多组数据,输入的第一行有两个数:N和M,接下来的M行每行有两个数a和b,表示了一条通道可以从A房间来到B房间。文件最后以两个0结束。

Output

对于输入的每组数据,如果任意两个房间都是相互连接的,输出"Yes",否则输出"No"。

Sample Input

3 3
1 2
2 3
3 1
3 3
1 2
2 3
3 2
0 0

Sample Output

Yes
No

Author

Gardon

我的代码

#include <bits/stdc++.h>
#define int long long
const int N = 1e5 + 5;
std::stack<int> sta;
int dfn[N],low[N],cnt = 0,c[N],tot = 0,siz[N];
std::vector<int> edge[N];
bool b[N];
void init(){//初始化
    cnt = 0;
    tot = 0;
    std::memset(dfn , 0 , sizeof (dfn));
    std::memset(low , 0 , sizeof (low));
    std::memset(siz , 0 , sizeof (siz));
    while (!sta.empty()){
        sta.pop();
    }
    for(int i = 0 ; i < N ; i ++){
        edge[i].clear();
    }
}
void tarjan(int x){
    dfn[x] = low[x] = ++ cnt;//第一次到达这个位置,标记
    sta.push(x);
    b[x] = true;
    for(auto y : edge[x]){
        if(!dfn[y]){//如果y没有走过,说明y是x的子节点
            tarjan(y);
            low[x] = std::min(low[x] , low[y]);//y能到达的地方x一定能到达,所以让low[x]的值指向最远的祖先
        }else{
            if(b[y])//说明y是x的祖先
                low[x] = std::min(low[x] , dfn[y]);
        }
    }
    if(low[x] == dfn[x]){//x是强连通分量的根节点
        ++ tot;//染色
        while (1){//利用栈的性质,一旦x == y说明x的所有子孙已经被处理好了
            int y = sta.top();
            sta.pop();
            b[y] = false;
            c[y] = tot;
            siz[tot] ++;
            if(x == y) break;
        }
    }
}
signed main() {
    std::ios::sync_with_stdio(0);
    std::cin.tie(0);
    std::cout.tie(0);

    int n,m;
    while (std::cin >> n >> m) {
        if(n == 0 && m == 0) break;
        init();
        for(int i = 1;i <= m;i ++){
            int a , b;
            std::cin >> a >> b;
            edge[a].push_back(b);
        }

        for(int i = 1;i <= n;i ++){
            if(! dfn[i])
                tarjan(i);
        }

        if(tot == 1){//只有一个强连通分量,说明图上所有点都是连通的
            std::cout << "Yes\n";
        }else{
            std::cout << "No\n";
        }
    }
    return 0;
}

题目分析

拓扑排序

拓扑排序要解决的问题是给一个有向无环图的所有节点排序。就是在不破坏节点先后顺序的前提下,把DAG拉成一条链,使得排在前面的节点不能依赖于排在后面的节点。

Kahn算法

1、找到所有入度为0的点,放入队列q中,作为起点。如果找不到起点,说明这个图不是DAG。不存在拓扑排序

2、获取队首元素u的值,并弹出,将u的所有边删除,可简单处理为与u相连的节点的入度- 1,如果重新出现了入度为0的点,放入q中作为心动2起点。

3、不断重复上面的过程,直到q为空

【模板】拓扑排序 / 家谱树

题目描述

有个人的家族很大,辈分关系很混乱,请你帮整理一下这种关系。给出每个人的后代的信息。输出一个序列,使得每个人的后辈都比那个人后列出。

输入格式

\(1\) 行一个整数 \(N\)\(1 \le N \le 100\)),表示家族的人数。接下来 \(N\) 行,第 \(i\) 行描述第 \(i\) 个人的后代编号 \(a_{i,j}\),表示 \(a_{i,j}\)\(i\) 的后代。每行最后是 \(0\) 表示描述完毕。

输出格式

输出一个序列,使得每个人的后辈都比那个人后列出。如果有多种不同的序列,输出任意一种即可。

样例 #1

样例输入 #1
5
0
4 5 1 0
1 0
5 3 0
3 0
样例输出 #1
2 4 5 3 1

我的代码

#include<bits/stdc++.h>
#define int long long
const int N = 105;
int in[N],out[N];
int n;
std::vector<int> edge[N];
std::queue<int> q;
void topological(){
    for(int i = 1;i <= n;i ++){
        if(!in[i]) {
            std::cout << i << " ";
            q.push(i);
        }
    }
    while (!q.empty()){
        int u = q.front();
        q.pop();
        for(auto v : edge[u]){
            in[v] --;
            if(!in[v]){
                std::cout << v << " ";
                q.push(v);
            }
        }
    }
}
signed main(){
    std::ios::sync_with_stdio(0);
    std::cin.tie(0);std::cout.tie(0);

    std::cin >> n;
    for(int i = 1 ; i <= n ; i ++){
        while (1) {
            int t;
            std::cin >> t;
            if (t == 0) break;
            edge[i].push_back(t);
            out[i]++;
            in[t]++;
        }
    }

    topological();
    return 0;
}

题目分析

topological就是对上述过程的简单模拟

基于DFS的拓扑排序

DFS深度搜索的原理,是沿着一条路径一直搜索到最底层,然后逐层回退。这个过程正好体现了点和点的先后关系,天然符合拓扑排序的原理。

一个DAG,如果只有一个点u是0入度的,那么从u开始DFS,DFS递归返回的顺序就是拓扑排序(是一个逆序)。DFS递归返回的首先是最底层的点,它一定是0出度点,没有后续点,是拓扑排序的最后一个点;然后逐步回退,最后输出的是起点u;输出的顺序是逆序。

Following Orders

题面翻译

给定一串变量以及部分变量的大小关系,求所有满足这些关系的排列(由小到大)

题目描述

PDF

输入格式

输出格式

样例 #1

样例输入 #1
a b f g
a b b f
v w x y z
v y x v z v w v
样例输出 #1
abfg
abgf
agbf
gabf
wxzvy
wzxvy
xwzvy
xzwvy
zwxvy
zxwvy

我的代码

#include<bits/stdc++.h>
const int N = 30;
int edge[N][N];
std::vector<int> characters;
int in[N];
int topo[N];
bool vis[N];
int f = 0;
void dfs(int x , int cnt){
    topo[cnt] = x;
    if(cnt == characters.size() - 1){
        for(int i = 0 ; i <= cnt ; i ++){
            char t = topo[i] + 'a';
            std::cout << t;
        }
        std::cout << "\n";
        return ;
    }

    vis[x] = 1;

    for(auto i:characters){
        if(!vis[i] && edge[x][i]){
            in[i] --;
        }
    }

    for(auto i:characters){
        if(!vis[i] && !in[i]){
            dfs(i , cnt + 1);
        }
    }

    for(auto i:characters){
        if(!vis[i] && edge[x][i]){
            in[i] ++ ;
        }
    }

    vis[x] = 0;
}
int main(){

    std::string s;
    int f = 0;
    while (getline(std::cin,s)) {
        if(f){
            std::cout << '\n';
        }

        f = 1;

        for(int i = 0 ; i < N ; i ++){
            std::memset(edge[i] , 0 , sizeof (edge[i]));
        }
        characters.clear();
        std::memset(in , 0 , sizeof(in));
        std::memset(topo , 0 , sizeof(topo));
        std::memset(vis , 0 , sizeof(vis));

        for (int i = 0; i < s.size(); i++) {
            if (s[i] >= 'a' && s[i] <= 'z') {
                characters.emplace_back(s[i] - 'a');
            }
        }

        std::sort(characters.begin(), characters.end());

        getline(std::cin, s);

        bool flag = true;
        char begin, end;
        for (int i = 0; i < s.size(); i++) {
            if (s[i] >= 'a' && s[i] <= 'z') {
                if (flag) {
                    begin = s[i];
                } else {
                    end = s[i];
                }
                if (!flag) {
                    edge[begin - 'a'][end - 'a'] = 1;
                    in[end - 'a']++;
                }

                flag = !flag;
            }
        }

        for(auto i : characters){
            if(in[i] == 0) dfs(i,0);
        }
    }

    return 0;
}

题目分析

这题的每组测试样例之间需要用换行隔开

首先我们对数据进行处理,为了方便,我们不妨将每个字符串减去'a'后,存入整型vector中,对所有的节点排序后,按拓扑序输出的各个节点天然有序。

最后按照类似上述BFS的原理,修改成dfs即可,注意回溯。

2-sat

有n个bool变量,其中一些bool变量之间存在制约关系;用所有n个bool变量组成序列,判断是否存在满足所有制约关系的序列,这称为SAT(Satisfiability)问题。如果每个限制关系只涉及两个变量,就是2-SAT问题。

实际背景

有n对夫妻被邀请参加一个聚会,因为场地的问题,每对夫妻中只有1人可以列席。在2n 个人中,某些人之间有着很大的矛盾(当然夫妻之间是没有矛盾的),有矛盾的2个人是不会同时出现在聚会上的。有没有可能会有n 个人同时列席?

用图论来描述2-sat问题

假设原命题公式有 $ x_1 , x_2 , x_3 , ... , x_n $ , 共\(n\)个命题变元,那么有\(2n\)个文字(如:\(x_1\)\(\lnot x_1\))

学过离散数学的朋友们都知道,"\(\rightarrow\)"可表示为图论中的"可达",用"\(\rightarrow\)"连接的命题变元存在这样的关系:

\(a\rightarrow b \Longleftrightarrow \lnot a \vee b\)

现在我们对每个命题变元进行讨论:

1、$ x_i \rightarrow \lnot x_i \Longleftrightarrow \lnot x_i \vee \lnot x_i $ ,说明\(x_i\)为假

2、$ \lnot x_i \rightarrow x_i \Longleftrightarrow x_i \vee x_i $ ,说明\(x_i\)为真

3、$ x_i \Longleftrightarrow \lnot x_i $ ,原命题矛盾,说明不存在满足制约关系的序列

4、\(x_i、\lnot x_i\) 互相不可达,那么\(x_i\)可以随意赋值

我们不妨以\(a_1,a_2\)\(b_1,b_2\),这两对夫妻为例,已知\(a_1\)\(b_2\)之间有矛盾,为了满足制约关系,我们不妨让 \(a_1 \rightarrow b_1\) 、$ b_2 \rightarrow a_2 $ 表示\(a_1\)参加的话一定是\(b_1\)参加,\(b_2\)参加的话一定是\(a_2\)参加。

建完边之后我们不妨用tarjan来划分SCC,同一个SCC中的点表示,选了其中一个,那么必须选择这个SCC中的其他点。如果存在\(a_1 ,a_2\)在同一个SCC中,则为3的情况,说明原命题矛盾。构造可行的方案只需要将,不矛盾的SCC拼起来即可。

根据我们上面的说明我们不难得到下面的解题步骤:

1、根据限制条件建图

2、使用tarjan求出SCC

3、如果每个SCC内都没有夫妻,则说明有合法的出席组合

那么我们应该如果求解一个合法的组合呢?

根据 伍昱 -《由对称性解 2-sat 问题》 我们可以知道:如果要输出一个2-SAT问题的可行解,需要将缩点后构造的新图进行topo排序,自底向上进行选择,最后输出结果

【模板】2-SAT 问题

题目描述

\(n\) 个布尔变量 \(x_1\)\(\sim\)\(x_n\),另有 \(m\) 个需要满足的条件,每个条件的形式都是 「\(x_i\)true / false\(x_j\)true / false」。比如 「\(x_1\) 为真或 \(x_3\) 为假」、「\(x_7\) 为假或 \(x_2\) 为假」。

2-SAT 问题的目标是给每个变量赋值使得所有条件得到满足。

输入格式

第一行两个整数 \(n\)\(m\),意义如题面所述。

接下来 \(m\) 行每行 \(4\) 个整数 \(i\), \(a\), \(j\), \(b\),表示 「\(x_i\)\(a\)\(x_j\)\(b\)」(\(a, b\in \{0,1\}\))

输出格式

如无解,输出 IMPOSSIBLE;否则输出 POSSIBLE

下一行 \(n\) 个整数 \(x_1\sim x_n\)\(x_i\in\{0,1\}\)),表示构造出的解。

样例 #1

样例输入 #1

3 1
1 1 3 0

样例输出 #1

POSSIBLE
0 0 0

提示

\(1\leq n, m\leq 10^6\) , 前 \(3\) 个点卡小错误,后面 \(5\) 个点卡效率。

由于数据随机生成,可能会含有( 10 0 10 0)之类的坑,但按照最常规写法的写的标程没有出错,各个数据点卡什么的提示在标程里。

我的代码

#include <bits/stdc++.h>
#define int long long
const int N = 1e6 + 5;
std::vector<int> edge[2 * N];
std::stack<int> sta;
int dfn[2 * N] , low[2 * N] , color[2 * N] , tot , cnt;
bool vis[2 * N];

void tarjan(int x){//缩点

    dfn[x] = low[x] = ++ cnt;
    vis[x] = true;
    sta.push(x);
    for(auto i : edge[x]){
        if(!dfn[i]){
            tarjan(i);
            low[x] = std::min(low[x] , low[i]);
        } else{
            if(vis[i]) {
                low[x] = std::min(low[x], dfn[i]);
            }
        }
    }

    if(low[x] == dfn[x]){
        tot ++;
        while (1){
            int t = sta.top();
            sta.pop();
            color[t] = tot;
            vis[t] = false;
            if(t == x) break;
        }
    }

}

void solve(){
    int n , m ;
    std::cin >> n >> m;
    for(int k = 1 ; k <= m ; k ++){
        int i , a , j , b;
        std::cin >> i >> a >> j >> b;
        int nota = a ^ 1 , notb = b ^ 1;//获得非a和非b
        edge[i + nota * n].push_back(j + b * n);//存非a -> b
        edge[j + notb * n].push_back(i + a * n);//存非b -> a
    }

    for(int i = 1 ; i <= 2 * n ; i ++){
        if(!dfn[i]){
            tarjan(i);
        }
    }

    for(int i = 1; i <= n ; i ++){
        if(color[i] == color[i + n]){//如果存在a和非a同时在一个SCC中,说明矛盾
            std::cout << "IMPOSSIBLE\n";
            return ;
        }
    }

    std::cout << "POSSIBLE\n";

    for(int i = 1 ; i <= n ; i ++){
        if(color[i] > color[i + n]){//选择topo序大的,即topo排序后排在后面的点
            std::cout << "1 ";
        }else{
            std::cout << "0 ";
        }
    }
    std::cout << '\n';
}

signed main(){
    std::ios::sync_with_stdio(0);
    std::cin.tie(0);std::cout.tie(0);

    int t = 1;
//    std::cin >> t;
    while (t --){
        solve();
    }
    return 0;
}

解释说明

Q:为什么不需要重新建图,再排topo序?

A:在罗老师的《算法竞赛》的2-sat的部分有进行说明,“在求SCC时,已经得到了每个点所属的SCC,SCC的序号就是一个topo序”

Q:topo序要从入度为0的点开始,但是tarjan不是从入度为0的点开始,你怎么能保证topo序是正确的呢?

A:tarjan算法本身是一个递归的写法,第一个被染色(主要是被染色,而不是标记)的点一定是没有后继的点,即为入度为0的点。所以每个点的color值就是它所在的SCC的topo序。