图论初步

· · 算法·理论

省流:本篇专供冲击NOIP一等的人使用,坐标HN

博客同步

没图床放图来写图论是真的蛋疼……

1.图的dfs/bfs

图论中的深度优先遍历dfs和广度优先遍历bfs的思想基础和深度优先搜索&&广度优先搜索基本一致。所以你为什么会不会?还不去复习搜索?

对于遍历,我们打一个vis数组,每遍历到一个点,就把它的vis设为truefalse也可以,但你自己要记得)

不同的图有不同的dfsbfs方法,事实上,对于不同的图论算法,建不同的图,写码难度不尽相同,你马上就会看到这点。

习题1.1.1

P5318 【深基18.例3】查找文献

这个基本上是板子题了,虽然可能有更板的题,但是我忘了是哪道了,不好放上来。

看看例题代码,认真体会遍历与搜索的区别和联系。

还有,如果图不能保证连通,就不要把点数n作为运算和输出的依据!!!

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxm=1e6+7;
const int maxn=1e5+7;

vector<int>e[maxm];
int vis[maxn];
int cnt;

void dfs(int n,int len)
{
    vis[n]++;
    cout<<n<<' ';
    for(int i=0;i<e[n].size();i++)
    {
        int v=e[n][i];
        if(!vis[v])

        {
            vis[v]++;
            dfs(v,len);
        }
    }
}

void bfs(int n,int len)
{
    queue<int> q;
    q.push(n);
    vis[n]++;
    cout<<n<<' ';
    while(!q.empty())
    {
        int u=q.front();
        q.pop();
        for(int i:e[u])
        {
            if(!vis[i])
            {
                vis[i]++;
                q.push(i);
                cout<<i<<' ';
            }
        }
    }
}

int main()
{
    int x=0,y=0;
    cin>>x>>y;
    for(int i=1;i<=y;i++)
    {
        int a=0,b=0;
        cin>>a>>b;
        e[a].push_back(b);
    }
    for(int i=0;i<=y;i++)
    {
        sort(e[i].begin(),e[i].end());
    }
    dfs(1,x);
    cnt=0;
    memset(vis,0,sizeof(vis));
    cout<<endl;
    bfs(1,x);
    return 0;
}

2.拓扑排序

拓扑排序吧,你说它很难绝对是不对的,但它牵扯到最长路,基环树、二维莫队这些玩意儿,你又不能说它简单。

拓扑排序可以把图变成一个一维序列,这个一维序列满足实际问题中的先后顺序

我一直都觉得这句话比较废话,你就姑且认为拓排可以把图变成一个有一定顺序的线性序列吧。

那怎么做呢?我们来思考。

我们考虑把实际问题中的事情抽象为点,事情的依赖(先后)关系抽象为有向边(无向边还算什么依赖?),为了讨论方便,我们将边的方向设定成先完成→后完成

实际问题中,最先要做的事情之前显然没有要做的事情(废话吧),抽象成图后,它所代表的点不会是任何一条边的终点(不会在哪个事件后完成),那么我们就找到拓排的起点了。

然后我们直接选择删掉这个点,以及以它为起点的所有边,那么所有的点连接的边的数目都会减一(剪掉重边的数目,如果有重边的话),如果再次有点满足起点的条件,那就把它扔进起点候选队列呗[doge]。

对于一个点所连接的边的数目,我们称为度数,对于有向图,以这个点为起点的边的数目称为出度,以这个点为终点的边的数目称为入度。显然一个点为起点的充要条件是入度为0。对于每个点的度数,我们直接开个degree数组维护就行。

值得一提的是,拓扑排序不仅要求图是有向的,并且要求图中没有环,什么意思呢?考虑以下邻接表数据,第一行为点数n和边数m

4 4
1 2
2 3
3 4
4 1

很明显整个图就是四元环,每个点都有先于它的点,显然是没有拓扑序的。因为找不出最先完成谁,即以谁为起点。

那么因为这种优良的对数据过敏的性质,我们可以用拓扑排序来判定一个有向图是否有环(忽略边权正负问题)。

我们设置一个cnt,每遍历到一个点就加一,如果cnt > n,那肯定是有点没删掉(不然就n个点,你怎么走到n后面去),这时,我们按照题目要求,该输出No输出No,该输出NO输出NO,该输出不可以,总司令就输出不可以,总司令

接下来就做个模板题吧~

习题2.1.1

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

没什么要注意的了,就把题A了就行了。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=205;
//#define DEBUG

int edges[maxn][maxn];
int degree[maxn];
queue<int>q;
int cnt=0;

void toposort(int s,int n)
{
    for(int i=1;i<=n;i++)
    {
        if(degree[i]==0)q.push(i);
    }
    int buf=0;
    while(!q.empty())
    {
        buf=q.front();
        q.pop();
        cout<<buf<<' ';
        cnt++;
        for(int i=1;i<=n;i++)
        {
            if(edges[buf][i]==1)
            {
                edges[buf][i]=0;
                degree[i]--;
                if(degree[i]==0)q.push(i);
            }
        }
    }
}

int main()
{
    int n=0,u=0,v=0;
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        do
        {
            cin>>v;
            edges[i][v]=1;
            degree[v]++;
        }
        while(v>0);
    }
    #ifdef DEBUG
    cout<<"edges:"<<endl;
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=n;j++)
        {
            cout<<edges[i][j];
            if(j<n)cout<<' ';
        }
        if(i<n)cout<<endl;
    }
    cout<<endl;
    cout<<"degree:"<<endl;
    for(int i=1;i<=n;i++)
    {
        cout<<degree[i];
        if(i<n)cout<<' ';
    }
    cout<<endl;
    cout<<endl;
    #endif
    toposort(u,n);
    return 0;
}

3.最小生成树

最小生成树MST,是指在一个图中,保留n-1条边,使其边权和最小。名字的来源是树有且只有n-1条边,可以证明,若有n条边,则必定会形成一个环,若边数小于n-1,则图不可能连通,即有点会没有边相连。

注意到对于无向图,最小生成树所形成的图是保证点两两连通边数最少的形式。这是它的实际意义。

对于有向图,情况比较复杂。我们可以采用朱刘算法,比如JSOI2008就有一道例题。但经验表明,在我们的目标等级,有向图的最小生成树并不多见,所以我们完全可以鸽掉,省一点时间。本文介绍求无向图的最小生成树两种经典算法。

3.1 kruskal

我们观察,发现既然最小生成树使点两两连通,那么所有的点必然都属于一个大的集合,我们可以称大集合为连通块。

那么我们可以考虑给每个点记录它所属的集合,然后将边按边权从小到大排序,从最小的边开始,一条一条边考虑。

我们选择一个边,当且仅当这条边的两个端点所属的集合不同。这样,我们就选择这条边,并将边权累加至答案以统计贡献。最后,将两个端点的集合合并。

当只剩下一个集合时,说明点已两两连通,满足MST的定义。此时可以输出答案。

如果边已完成遍历,而仍然有多个集合时,我们认为这张图的MST无解,这可能是有点不是任何一条边的端点造成的。

描述很啰嗦,但是写起来还是很容易的,而且kruskal基于并查集的思想,在优化算法下,并查集的复杂度为O(n\alpha(n)),其中\alpha(n)为阿克曼函数的反函数,在算法竞赛的规模中一般是不大于4的常数,我们可以认为是个大常数的O(n),再不济,kruskal的复杂度也是O(n\log n)。好写、跑得快,场上可以优先选择。

习题3.1.1

P3366 【模板】最小生成树

OK,动手!

如果图不连通,你应当输出orz

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=5e3+5;
const int maxm=2e5+7;

int fa[maxn];
int sz[maxn];

void set_make(int len)
{
    for(int i=1;i<=len;i++)
    {
        fa[i]=i;
        sz[i]=1;
    }
}

struct edge
{
    int a;
    int b;
    int c;
};

edge edges[maxm];

bool operator <(edge x,edge y)
{
    if(x.c<y.c)return true;
    else return false;
}

int find(int n)
{
    if(fa[n]==n)return fa[n];
    else return fa[n]=find(fa[n]);
}

bool merge(int x,int y)
{
    x=find(x);
    y=find(y);
    if(x==y)return false;
    else
    {
        //if(sz[x]>sz[y])swap(x,y);
        fa[x]=y;
        sz[y]+=sz[x];
        return true;
    }
}

/*bool check(int len)
{
    int cnt=0;
    for(int i=1;i<=len;i++)
    {
        cout<<fa[i]<<' ';
        if(fa[i]==i)
        {
            cnt++;
            if(cnt>1)return false;
        }
    }
    cout<<endl;
    return true;
}*/

int main()
{
    int n=0,m=0,res=0;
    cin>>n>>m;
    set_make(n);
    for(int i=1;i<=m;i++)
    {
        cin>>edges[i].a>>edges[i].b>>edges[i].c;
    }
    sort(edges,edges+m+1);
    for(int i=1;i<=m;i++)
    {
        //if(m==1)cout<<edges[1].c; //不需要考虑
        /*if(check(n)==true)
        {
            cout<<res;
            return 0;
        }*/
        if(merge(edges[i].a,edges[i].b)==false)
        {
            //cout<<"DON'T"<<endl;
            continue;
        }
        else
        {
            //cout<<"Add edge between "<<edges[i].a<<' '<<" and "<<edges[i].b<<endl;
            res+=edges[i].c;
            n--;
            //cout<<"Now there are "<<n<<" sets"<<endl;
            if(n==1)
            {
                cout<<res;
                return 0;
            }
        }
    }
    cout<<"orz";
    return 0;
}

3.2 prim

考虑前面对于MST的定义,注意到“选取最小的边”这条要求也可以等价为每个点所连接的边的边权最小。这样,我们可以先确定每个点的答案,再累加后输出。

怎么确定每点的答案呢?根据一些教材的说法,如果没有最短路的基础,理解prim的思想会有些困难,事实上,没有prim的基础,理解最短路的思想也会有些困难。[doge]总之它们写法相近就是了。

我们先设立mins数组,每个元素初始化为一个极大的数(要求的是最小生成树)

然后我们选取一个点x作为起点,将mins[i]0

我们从当前的mins中选取值最小的一个位置,并遍历它的每条边,对于每条边的另一个端点,如果它没有被访问过,且当前边的边权小于它的mins,那么显然可以修改它的mins

这个思想显然是贪心,可以证明这个贪心的正确性。

如果在算法完成后,仍有点的mins等于初始化的最大值,那么显然它没有任何边连接,即整张图不连通,我们认为这张图的MST无解。

习题3.2.1

P3366 【模板】最小生成树

AC Code: ```cpp #include <bits/stdc++.h> using namespace std; const int maxn=5e3+5; const int maxm=2e5+7; struct edge { int to; int val; }; vector<edge>graph[maxn]; int mins[maxn]; int vis[maxn]; inline void adde(int u,int v,int w) { graph[u].push_back({v,w}); } int findmin(int len) { int ans=0; for(int i=1;i<=len;i++) { if((mins[i]<mins[ans])&&(!vis[i])) { ans=i; } } return ans; } void prim(int n,int s=1) { memset(mins,0x3f,sizeof(mins)); memset(vis,0,sizeof(vis)); mins[s]=0; int res=0; for(int i=1;i<=n;i++) { int u=findmin(n); vis[u]=1; for(int j=0;j<graph[u].size();j++) { if((!vis[graph[u][j].to])&&(graph[u][j].val<mins[graph[u][j].to])) { mins[graph[u][j].to]=graph[u][j].val; } } } for(int i=1;i<=n;i++) { if(mins[i]==0x3f3f3f3f) { cout<<"orz"; return; } res+=mins[i]; } cout<<res; } int main() { int n=0,m=0,u=0,v=0,w=0; cin>>n>>m; for(int i=1;i<=m;i++) { cin>>u>>v>>w; adde(u,v,w); adde(v,u,w); } for(int i=1;i<=n;i++) { if(mins[i]==0x3f3f3f3f) { cout<<"orz"; return 0; } } prim(n,1); return 0; } ``` # 4.最短路 对于图的每个节点,我们可以认为它们有固定的“位置”,而边构成了一条条“路”,加上边权以后,自然就有了“**最短路**”。 对于无边权的最短路,$bfs$就可以搞定,因为边权都一样的话,显然走过的边越少,路径就越短。 本文简单介绍三种求最短路的常用算法,注意到这些算法对**边的方向**并无要求。 ## 4.1 floyd $floyd$算法解决的是所谓“全源最短路”问题。就是写个函数跑一遍,每两点之间的最短路都出来了。 现在我们考虑怎么做。有个显然的想法是贪心,在两点之间选取最短的边构成一条路。但是这个算法写出来显然很复杂,有人($floyd$)就想了个更简洁的算法。 什么算法最简洁呢?不考虑数学问题的情况下,简单DP无非是最简单的。用几层循环填答案数组就可以了。那我们就往这个思想方向靠。 那怎么设$dp$数组表示状态呢?设$dp[i][j]$表示由$i$到$j$的最短路?那不变成之前的贪心了。 $floyd$老爷子是这么想的,设$dp[i][j][k]$表示经过$k$点,由$i$到$j$的最短路,显然我们就可以分段讨论,得到一个状态转移方程: $$dp[i][j][k]=dp[i][k][k]+dp[k][j][k]$$ $$if$$ $$dp[i][j][k]>dp[i][k][k]+dp[k][j][k]$$ 显然有初始条件 $dp[i][i][k]=0 dp[i][j][k]=graph[i][j],if$ $graph[i][j]!=0 if$ $graph[i][j]==0,dp[i][j][k]=0x3f3f3f3f k \in [1,n]

我们发现这个dp数组太大了,大到只能承受约1e2个点,不满足我们的需要。

注意到数组k一维在状态转移方程中并无实质性作用,而且最终答案还要处理成\max_{k=1}^n(dp[i][j][k]),增大隐形常数。所以,我们可以考虑将k一维舍去,只保留dp[i][j],整理其他式子。

但在循环中,我们还需要保留k的处理,因为仍然有k。所以仍有三层循环。

尝试做模板题吧,最终的状态转移方程形式优美、简洁,充满魅力! ### 习题4.1.1 [P3647 【模板】floyd](https://www.luogu.com.cn/problem/B3647) 注意到该题有重边,输入时就应选择最小的边存储。 AC Code: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn=1e2+5; const int maxm=4.5e3+7; int graph[maxn][maxn]; int res[maxn][maxn]; void build(int num,int n) { int a=0,b=0,c=0; memset(graph,0x3f,sizeof(graph));//初始边权为正无穷 for(int i=1;i<=num;i++) { cin>>a>>b>>c; if(c<graph[a][b])graph[a][b]=graph[b][a]=c;//模板题有重边,需要特判 } for(int i=1;i<=n;i++) { graph[i][i]=0;//自环设为零 } } void floyd(int sz) { for(int i=1;i<=sz;i++) { res[i][i]=0;//自环等于零 for(int j=1;j<=sz;j++) { res[i][j]=(graph[i][j]==0x3f3f3f3f?0x3f3f3f3f:graph[i][j]);//有边为边权,没边就无穷大 } } for(int k=1;k<=sz;k++) { for(int i=1;i<=sz;i++) { for(int j=1;j<=sz;j++) { res[i][j]=min(res[i][j],res[i][k]+res[k][j]); } } } for(int i=1;i<=sz;i++) { for(int j=1;j<=sz;j++) { cout<<res[i][j]; if(j<sz)cout<<' '; } if(i<sz)cout<<endl; } } int main() { int n=0,m=0; cin>>n>>m; build(m,n); floyd(n); return 0; } ``` ## 4.2 spfa > 关于$spfa$,它死了——NOI 2018 T1 归程 $spfa$是$bellman-ford$算法的队列改进版,时间复杂度据说常数更优,~~但还是很容易被卡~~。即使如此,这里也只讲$spfa$。 $spfa$解决的是“单源最短路”问题,即跑一个函数,终点可以$n$个,但起点只能有一个。 我们考虑能不能对$bfs$做点优化,使它带边权。 对于最短路,同样的,我们设个$dist[maxn]$来解决这个问题。 首先,我们让起点入队,设其$dist$为$0$,并打上标记。 然后照常$bfs$循环,但是我们此时取出队头后,我们需要重新将该点的$vis$设为$false$,因为求最短路时,一个点很有可能要多次访问,所以先取消掉。 然后,我们就循环所有相连的点,然后…… 是传说中的**松弛**!!! ```cpp for(int i=head[u];i!=0;i=edges[i].last) { int v=edges[i].to; if(dist[v]>dist[u]+edges[i].val) { dist[v]=dist[u]+edges[i].val; if(!vis[v]) { q.push(v); vis[v]=true; } } } ``` 这就是我们对$bfs$进行的改造,简单有效,~~而且确实长得跟$prim$挺像的~~。 所以你也知道了,$spfa$也是贪心,确定了每个点的答案。 > $spfa$的设计策略是**每次确定一个点** ~~我总结的奇怪规律~~ 值得一提的是,$spfa$受的住负边权,但受不住负环。同样的,你就可以用它来判负环,详见$5.2节

习题4.2.1

P3371 【模板】单源最短路径(弱化版)

OK,动手!

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e4+5;
const int maxm=4.5e+7;

struct edge
{
    int to;
    int last;
    int val;
};
edge e[maxm];
int head[maxn];
int vis[maxn];
int dist[maxn];
queue<int>q;
int tot=0;

inline void adde(int a,int b,int c)
{
    e[++tot].last=head[a];
    e[tot].to=b;
    e[tot].val=c;
    head[a]=tot;
}

void spfa(int n,int x=1)
{
    memset(dist,0x3f,sizeof(dist));
    q.push(x);
    dist[x]=0;
    vis[x]=1;
    while(!q.empty())
    {
        int u=q.front();
        q.pop();
        vis[u]--;
        for(int i=head[u];i!=0;i=e[i].last)
        {
            int v=e[i].to;
            if(dist[v]>dist[u]+e[i].val)
            {
                dist[v]=dist[u]+e[i].val;
                //cnt[i]=cnt[u]+1;
                //if(cnt[i]>=n)return false;
                if(!vis[v])
                {
                    vis[v]++;
                    q.push(v);
                }
            }
        }
    }
    //return true;
}

int main()
{
    int n=0,m=0,s=1;
    cin>>n>>m>>s;
    //build(n,m);
    int a=0,b=0,c=0;
    for(int i=1;i<=m;i++)
    {
        cin>>a>>b>>c;
        adde(a,b,c);
    }
    spfa(n,s);
    for(int i=1;i<=n;i++)
    {
        if(dist[i]==0x3f3f3f3f)
        {
            cout<<2147483647;
            if(i<n)cout<<' ';
            continue;
        }
        cout<<dist[i];
        if(i<n)cout<<' ';
    }
    return 0;
}

其实spfa为什么会被卡的这么死,我也不是很清楚,但是只要没负边权,你就用下面这个算法吧。

4.3 dijkstra

然后我们在$spfa$的基础上做些小改动,简单来说就是定义一个$node$结构体,把每个点进行一次松弛后的距离和端点存起来,我们以这个结构体为基础,开一个$priority$ $queue$,然后其余的和$spfa$一致。 ### 习题4.3.1 [P4779 【模板】单源最短路径(标准版)](https://www.luogu.com.cn/problem/P4779) AC Code: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn=1e5+5; const int maxm=2e5+7; struct edge { int to; int last; int val; }; struct node { int dis; int s; friend bool operator <(node a,node b) { return a.dis>b.dis; } }; edge e[maxm*2]; int head[maxn]; int vis[maxn]; int dist[maxn]; priority_queue<node>q; int tot=0; inline void adde(int a,int b,int c) { e[++tot].last=head[a]; e[tot].to=b; e[tot].val=c; head[a]=tot; } void dijkstra(int n,int x=1) { memset(dist,0x3f,sizeof(dist)); q.push({0,x}); dist[x]=0; vis[x]=1; while(!q.empty()) { int u=q.top().s; q.pop(); vis[u]--; for(int i=head[u];i!=0;i=e[i].last) { int v=e[i].to; if(dist[v]>dist[u]+e[i].val) { dist[v]=dist[u]+e[i].val; if(!vis[v]) { vis[v]++; q.push({dist[v],v}); } } } } } int main() { int n=0,m=0,s=1; cin>>n>>m>>s; int a=0,b=0,c=0; for(int i=1;i<=m;i++) { cin>>a>>b>>c; adde(a,b,c); } dijkstra(n,s); for(int i=1;i<=n;i++) { if(dist[i]==0x3f3f3f3f) { cout<<2147483647; if(i<n)cout<<' '; continue; } cout<<dist[i]; if(i<n)cout<<' '; } return 0; } ``` 关于$spfa$和$dijkstra$到底有什么区别,看一看[我的远古口嗨](https://www.luogu.com.cn/article/wkjqggjn)$may$ $help$. 虽然也写的不是很清楚,但够用了。 # 5.图论著名问题选讲 以下讲解一些OI图论中的著名问题,他们基本都是以前的算法的改装版。 学习时,应重点搞懂$tarjan$老爷子的各种思想,尤其是求强连通分量的算法,它是很多算法的基础。 ## 5.1 强连通分量&&缩点 曾经,当我的教练跟我提起强连通分量是什么的时候,我思考了一下,回答:“一个图的最大子集”。 她当时估计想呼死我:“一个图的最大子集是它本身!!!” 事实上,强连通分量确实是图的一个子集,但是,这个子图中,每个点两两**直接有边**连通,非常的牛逼。 那我们怎么找出一个图的强连通分量?$tarjan$老爷子提出了一个奇妙的Solution。他的想法啊,是基于$dfs$的。 具体来说,我们设两个数组,$dfn$和$low$,前者是访问的时刻(第几个被访问的),后者是该点能追溯到的最早访问的点是第几个被访问的。这个“个”我们可以设个$idx$控制。因为一开始还没访问,所以这两个数组都应初始化为0,然后$dfn$就可以代替朴素$dfs()$的$vis$了嘛。不要再定义一个$vis$了!我就被这个搞混了!( 而且为了好判断节点所属强连通分量,我们开一个栈,也是$dfs()$的老习惯了。 首先肯定是开始访问,入栈,$dfn$和$low$都设为$++idx$,比较好理解吧。 然后照例遍历所有相邻节点,如果没有被访问过,也就是$dfn$为空,~~还愣着干嘛快$dfs$啊~~。既然能够一路访问下去,那就说明这一路上能到的最早节点都是一样的,所以回溯时要把这一路的$low$都改,改成最小的,用$min$,而且是用每条边两个端点的$low$取最小值。 如果这个点已经被访问过了,那$dfn$肯定就不等于0了。但是如果它没有被人要走,那我们也可以拉它入伙,毕竟也是一条路上的嘛。如果开一个$scc$数组记录每个原先节点所属的强连通分量,没被人要走就意味着这个值仍然为零。碰到这种情况,我们也用两个$low$更新一下$low$。 当我们跳出这个边的循环以后,$dfn$和$low$肯定都更新好了,那我们怎么判断强连通分量呢? 注意到由于一个强连通分量里任两点都可以直接连通,所以一个强连通分量内的点必然$low$都是相同的,而且我们还可以猜想,在这个分量里,必然有一个点,满足$dfn==low$,因为就初值来看,能做到$low$最小,必然有$dfn$最小,因为它们都被初始化成了$++idx$。所以,在跳出遍历边的循环后,我们直接判断有没有点$dfn==low$。如果有的话,就开始弹栈,为了标记数目,我们再设个$scccnt$变量,一旦进入这个分支就++,然后出栈一个元素并用$scccnt$标记它的$scc$,同时如果有需要,也维护下强连通分量的大小。 这个出栈循环的终止条件是栈顶元素不为这个$dfn==low$的节点。道理很简单,因为它已经出过一次了(进这个循环代码),如果这时不退,就会无限循环了。 由于一个讨厌的**图不连通**的问题,如果题面没有保证图联通,那就要将每个未访问的点都$tarjan$一遍,不然有些孤点就找不到了……TAT 可以证明对全部点跑$tarjan$的复杂度是$O(n+m)$,因为$dfn$可以作为$vis$,所以不存在点被重复访问的情况。 ### 习题5.1.1 [P2341 [USACO03FALL / HAOI2006]受欢迎的牛 G ](https://www.luogu.com.cn/problem/P2341) 其实这一题就用到了缩点……因为每个强连通分量内部两两直接连通,所以可以直接视为一个“大”点。这样,我们只要保留每个“大点”之间的边就可以了。具体的操作是开个$from$和$to$数组。每读一条边进原邻接表,也存一份在这两个数组里,然后跑完所有$tarjan$就可以把原邻接表清空,装新的邻接表了! 本题新建边时还要维护一下“大点”的出度。有一个小结论:缩完点后,**有解当且仅当只有一个“大点”的出度为零,且解就是这个“大点”的大小**。 证明是(居然给证明了): - 如果有两个以上出度为零的“大点”,那么这些“大点”之间彼此无法爱慕,不符合题意,因此无解。 - 如果出度为零的“大点”一个都没有: 首先,我们有个结论:**缩完点后的图如果是有向的,就不存在双向边** 这个结论的正确性是显然的,在有向图中,如果两个点之间有双向边,那么它们之间就**可以看作相互连通**。显然可以继续缩成一个点( $\therefore$证毕 那如果没有出度不为0的点,即每个点都要有一条通向其他点的边,那即使其他点都指向一个点$x$,它自己也得指向一个点,这个点就无法指向它,也就不满足爱慕的条件了。 - 因为每个大点内部都是两两连通的,所以原图中如果有一个属于强连通分量的点符合条件,那么同分量的点肯定都满足条件。 $\therefore$证毕。 AC Code: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn=1e5+5; const int maxm=5e5+7; struct edge { int to; int last; }; edge edges[maxm*2]; int head[maxn]; int tot=0; int dfn[maxn]; int low[maxn]; stack<int> s; int idx=0; int scccnt=0; int scc[maxn]; int sccsize[maxn]; int val[maxn]; int from[maxn]; int to[maxn]; int degree[maxn]; int res=0; int isres=0; inline void adde(int u,int v) { edges[++tot].last=head[u]; edges[tot].to=v; head[u]=tot; } void strongtarjan(int x) { s.push(x); dfn[x]=low[x]=++idx; for(int i=head[x];i!=0;i=edges[i].last) { int v=edges[i].to; if(!dfn[v]) { strongtarjan(v); low[x]=min(low[x],low[v]); } else if(scc[v]==0) { low[x]=min(low[x],low[v]); } } if(dfn[x]==low[x]) { scccnt++; int u=0; do { u=s.top(); s.pop(); scc[u]=scccnt; sccsize[scccnt]++; } while(x!=u); } } int main() { int n=0,m=0,u=0,v=0; cin>>n>>m; for(int i=1;i<=m;i++) { cin>>u>>v; adde(u,v); from[i]=u; to[i]=v; } for(int i=1;i<=n;i++) { if(!dfn[i])strongtarjan(i); } memset(head,0,sizeof(head)); memset(edges,0,sizeof(edges)); tot=0; for(int i=1;i<=m;i++) { if(scc[from[i]]!=scc[to[i]]) { adde(scc[from[i]],scc[to[i]]); degree[scc[from[i]]]++; } } for(int i=1;i<=scccnt;i++) { if(degree[i]==0) { res=i; isres++; } } cout<<(isres==1?sccsize[res]:0); return 0; } ``` ### 习题5.1.2 [P3387 【模板】缩点](https://www.luogu.com.cn/problem/P3387) 好像题目顺序摆错了……没什么好讲的。 这题要求最长路,可以用$lpfa$解决,松弛的不等号反向就可以了。 AC Code: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn=1e5+5; const int maxm=5e5+7; struct edge { int to; int last; }; edge edges[maxm*2]; int head[maxn]; int tot=0; int dfn[maxn]; int low[maxn]; int vis[maxn]; stack<int> s; int idx=0; int scccnt=0; int scc[maxn]; int sccsize[maxn]; int val[maxn]; int from[maxn]; int to[maxn]; int res=0; queue<int>q; int dist[maxn]; inline void adde(int u,int v) { edges[++tot].last=head[u]; edges[tot].to=v; head[u]=tot; } void spfa(int x) { vis[x]=1; q.push(x); dist[x]=sccsize[x]; while(!q.empty()) { int u=q.front(); q.pop(); vis[u]=0; for(int i=head[u];i!=0;i=edges[i].last) { int v=edges[i].to; if(dist[u]+sccsize[v]>dist[v]) { dist[v]=dist[u]+sccsize[v]; if(!vis[v]) { q.push(v); vis[v]=1; } } } } for(int i=1;i<=scccnt;i++) { res=max(res,dist[i]); } } void strongtarjan(int x) { s.push(x); dfn[x]=low[x]=++idx; vis[x]=1; for(int i=head[x];i!=0;i=edges[i].last) { int v=edges[i].to; if(!dfn[v]) { strongtarjan(v); low[x]=min(low[x],low[v]); } else if(vis[v]) { low[x]=min(low[x],dfn[v]); } } if(dfn[x]==low[x]) { scccnt++; int u=0; do { u=s.top(); vis[u]=0; s.pop(); scc[u]=scccnt; sccsize[scccnt]+=val[u]; } while(x!=u); } } int main() { int n=0,m=0,u=0,v=0; cin>>n>>m; for(int i=1;i<=n;i++) { cin>>val[i]; } for(int i=1;i<=m;i++) { cin>>u>>v; adde(u,v); from[i]=u; to[i]=v; } for(int i=1;i<=n;i++) { if(!dfn[i])strongtarjan(i); } memset(head,0,sizeof(head)); memset(edges,0,sizeof(edges)); tot=0; for(int i=1;i<=m;i++) { if(scc[from[i]]!=scc[to[i]]) { adde(scc[from[i]],scc[to[i]]); } } for(int i=1;i<=scccnt;i++) { spfa(i); } cout<<res; return 0; } ``` ## 5.2 传递闭包 你在网上搜搜,搜出来的关键词就不是这个内容( 所谓传递闭包,讲的高大上,实质上就是判断**一个图中任意两点能不能彼此到达**。因为是任意两个点,我们显然可以跑全源最短路,预先给数组初始化一个极大值。如果跑完以后,我们发现那两个点的最短路值依然是那个最大值,那就说明这两点,彼此根本无法到达。这也是我们想要的结果。 但是有人觉得这么搞不够快~~他们就是快男~~,所以我们考虑如何优化。 由于是任意两点,我们直接考虑对全源最短路模型修改。那肯定是floyd莫属了。 我们建边时,把边权去掉,每条边只采用$0-1$连通形式,然后,对于任意两个点,我们枚它们的中间点,那么,两点可以到只有以下两个条件: - 这两点本身有边连通 - 有至少一个“中转点”,使得这两点连通。 然后我们回想$floyd$的状态转移方程(经过优化): $$dp[i][j]=dp[i][k]+dp[k][j]$$ 显然这个状态转移方程是和第二种情况等价的,那么我们只要把第一种情况加进去就可以了。 两种情况应该取**并**的关系,因为如果如果同时满足既直接连通,又有中转点,那肯定也是连通的,所以,我们对状态转移方程做出如下改动: $$dp[i][j]=dp[i][j]||(dp[i][k]\&\&dp[k][j])$$ 刚好还是floyd的三层循环!$good

这个有什么用吧,没见过。只是有些题解说要提前判两点连通,可能会用这个?难绷。

习题5.2.1

B3611 【模板】传递闭包

它甚至是入门题库……

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=105;

int graph[maxn][maxn];
bool dis[maxn][maxn];

int main()
{
    int n=0;
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=n;j++)
        {
            cin>>graph[i][j];
            dis[i][j]=(graph[i][j]==1?true:false);
        }
    }
    for(int k=1;k<=n;k++)
    {
        for(int i=1;i<=n;i++)
        {
            for(int j=1;j<=n;j++)
            {
                dis[i][j]=dis[i][j]||(dis[i][k]&&dis[k][j]);
            }
        }
    }
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=n;j++)
        {
            cout<<dis[i][j];
            if(j<n)cout<<' ';
        }
        if(i<n)cout<<endl;
    }
    return 0;
}

5.3 判负环&&差分约束

因为是环,肯定是一个点跑了一圈发现回到了自己,所以我们采用单源最短路的做法。

诶,你要找负环嘞!关于dijkstra,它死了。关于spfa,它复活了

与拓扑排序的思想类似,如果我们用spfa跑了超过n个点,那肯定也是出现了个环,我们退出来就行了。但是由于spfa并没有固定的vis标记(取队头后vis置零),那我们要头疼的就有很多了。

我们的解决方案是拿空间换编码难度,直接开个cnt数组记录每个点的路过,如果有一个点经过了n次以上,那总不对劲了吧!同理,松弛成功后,我们也应该在cnt基础上转移,而不是简单的++。

再提一句,正权会收敛,最终队列会为空,所以spfa只能判负环。

习题5.3.1

P3385 【模板】负环

你别觉得这个很简单,拿spfa模板改一晚你都不一定能过……

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=2e3+5;
const int maxm=5e6;

struct edge
{
    int to;
    int last;
    long long val;
};
edge edges[maxm*2];
int head[maxn];
int vis[maxn];
int cnt[maxn];
long long dist[maxn];
queue<int>q;
int tot=0;

inline void adde(int a,int b,int c)
{
    edges[++tot].to=b;
    edges[tot].last=head[a];
    edges[tot].val=c;
    head[a]=tot;
}

bool spfa(int n,int s)
{
    memset(dist,0x3f,sizeof(dist));
    memset(vis,0,sizeof(vis));
    q.push(s);
    dist[s]=0;
    vis[s]=1;
    cnt[s]++;
    while(!q.empty())
    {
        int u=q.front();
        q.pop();
        vis[u]=0;
        for(int i=head[u];i!=0;i=edges[i].last)
        {
            int v=edges[i].to;
            if(dist[v]>dist[u]+edges[i].val)
            {
                dist[v]=dist[u]+edges[i].val;
                if(!vis[v])
                {
                    cnt[v]++;
                    if(cnt[v]>=n)return false;
                    q.push(v);
                    vis[v]=1;
                }
            }
        }
    }
    return true;
} 

int main()
{
    int t=0,n=0,m=0,u=0,v=0,w=0;
    bool flag=true;
    cin>>t;
    for(int i=1;i<=t;i++)
    {
        while(!q.empty())
        {
            q.pop();
        }
        memset(head,0,sizeof(head));
        memset(edges,0,sizeof(edges));
        memset(cnt,0,sizeof(cnt));
        tot=0;
        cin>>n>>m;
        for(int i=1;i<=m;i++)
        {
            cin>>u>>v>>w;
            if(w>=0)
            {
                adde(u,v,w);
                adde(v,u,w);
            }
            else
            {
                adde(u,v,w);
            }
        }
        flag=spfa(n,1);
        cout<<(flag==false?"YES":"NO");
        if(i<t)cout<<endl;
    }
    return 0;
}

好,接下来是判负环的最重要应用:差分约束

差分约束,也常叫差分约束系统,指的是对于如下不等式组:

\begin{cases} x_1-x_{1'}=c_1\\ x_2-x_{2'}=c_2\\ ......\\ x_n-x_{n'}=c_n \end{cases}

问是否能找到一组整数解,使得所有的不等式都成立,而且可以证明,对于有解的情况,必然有无数组解,因为在一组解上加上任意一个正整数都能使原不等式成立,这是不等式的基本性质。

那么,我们的问题就是怎么判无解。

首先,我们考虑能不能将其转化为一个信息学中有算法的问题,不然这事儿应该左转MO

注意到我们学单源最短路的时候学的神操作:松弛。它的式子一般是这样的:

dist[v]+edges[i].val<dist[u]

诶,不错哦!看来每一条这样的式子都对应了一条边呢!那我们就考虑用跑单源最短路,把这些解搞出来。

我们把一个原不等式建模成由被减数连向减数的一条权值为c的边。那么我们跑出来的dist数组肯定就是一组解!

那我们再联想,spfa刚不说过了吗,无解的情况是有负环,那我们判无解可不可以借助这条性质呢?

答案是可以的。因为就算原不等式组你减我,我减他,他减你这么形成一个环,如果是正环,spfa可以自己消掉,无所畏惧。但是负环的话,几个未知数可以通过这个环不断自减,最后减到-\infty。事实上,在不等式中,减负数是能改变方向的,所以负环不行。

关于为什么会有负边权,为什么dijkstra不行,我只能说差分约束问题的难点在建边,如何把原不等式正确的转化成图的边。这里实在无力证明,从网站上扒张可信的表吧(

转化成x_b-x_a\le -c,加边为adde(a,b,-c)

转化成x_a-x_b\le c,加边为adde(b,a,c)

转化成x_a-x_b\le 0\&\&x_b-x_a\le 0,加边为adde(b,a,0),adde(a,b,0)

习题5.3.2

P5960 【模板】差分约束

说实话,这个也没怎么用过……

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
const int maxm=5e6;

struct edge
{
    int to;
    int last;
    long long val;
};
edge edges[maxm*2];
int head[maxn];
int vis[maxn];
int cnt[maxn];
long long dist[maxn];
queue<int>q;
int tot=0;

inline void adde(int a,int b,int c)
{
    edges[++tot].to=b;
    edges[tot].last=head[a];
    edges[tot].val=c;
    head[a]=tot;
}

bool spfa(int n,int s)
{
    memset(dist,0x3f,sizeof(dist));
    memset(vis,0,sizeof(vis));
    q.push(s);
    dist[s]=0;
    vis[s]=1;
    cnt[s]++;
    while(!q.empty())
    {
        int u=q.front();
        q.pop();
        vis[u]=0;
        for(int i=head[u];i!=0;i=edges[i].last)
        {
            int v=edges[i].to;
            if(dist[v]>dist[u]+edges[i].val)
            {
                dist[v]=dist[u]+edges[i].val;
                if(!vis[v])
                {
                    cnt[v]++;
                    if(cnt[v]>=n+1)return false;
                    q.push(v);
                    vis[v]=1;
                }
            }
        }
    }
    return true;
} 

int main()
{
    int n=0,m=0,u=0,v=0,w=0;
    bool flag=true;
    cin>>n>>m;
    for(int i=1;i<=m;i++)
    {
        cin>>u>>v>>w;
        adde(v,u,w);
    }
    for(int i=1;i<=n;i++)
    {
        adde(0,i,0);
    }
    flag=spfa(n,0);
    if(flag==false)
    {
        cout<<"NO";
        return 0;
    }
    for(int i=1;i<=n;i++)
    {
        cout<<dist[i];
        if(i<n)cout<<' ';
    }
    return 0;
}

5.4 割点/割顶/割边/桥

这两个知识点名字是真的多……

割点和割边就是指删掉这个点或边以后,能使原图中的强连通分量增加,就是这么个意思。因为割边感觉像是一座桥,如果砍断,两边就失去了联系(即强连通分量增加),所以又把割边叫桥。割点下面再说。

如何判定呢?我们可以考虑删掉每条点或边,然后暴力tarjan判断。复杂度直接上升到O(n(n+m))或者O(m(n+m)),题目容易超时,那我们要不要再考虑一下直接对tarjan做个优化,维持原复杂度呢?

我们就来研究下割点和割边的性质,先研究割点的:

有一个感性的想法,割点一定是某个SCC的点对外连通的点,这样把它割掉以后这个SCC就没有对外连通的点了,free了,肯定可以使连通块个数增加。而且可以证明如果一个对外连通的点是割点,那么它所在的SCC一定只有一个对外连通的点,反之,这个点割掉了,SCC里的其他点还有路能够连通图的其他部分,那肯定不能增加SCC的数目,就不满足定义了(

上面这句加粗的话还是比较抽象,我们可以更加具化一点嘛?其实就是,对于后面搜到的SCC,它能连通的图中其他的点必然都比它先遍历,怎么衡量这个“先遍历” 呢?诶,用dfnlow吧!

一个点x是割点,当且仅当\exist一个点y是SCC内部的点,y最早能到达的点都比x后遍历,或者刚好能够到x,也就是low[y]\ge dfn[x]。这样,y不经过x,就无法到达SCC外的点,那把x割掉,y就“与世隔绝”了,SCC的数目就增加了。

注意这里不要求x所在的SCC里的所有的点都满足,而是只要一个就够了。就算只能能隔离一个,只要SCC数目增加了,也是割点嘛!

而且我们可以不用把每个点的SCC判出来再判割点,那样会提高复杂度,我们发现在tarjan回溯时就可以确定每个点的low了,那我们就直接在回溯时紧接着low[x]的更新判定low[y]\ge dfn[x]。这样不会增长复杂度,还是tarjanO(n+m)

但是对根节点(你可以认为是在main里调用tarjan时传入的节点)需要进行特判,它至少要有两个点满足这种情况才能看成割点,为什么呢?我们看一幅无向图:

4 5
1 2
1 4
2 3
2 4
3 4

假如我们从1搜进去,那么根节点就是1,此时,你把1割掉,2,3,4依然能构成一个SCC,SCC的数目由1到1,不满足割点的定义。

那为什么要把割点叫做割顶呢?因为在一个图上,一个SCC基本表现为一个多边形,那点不就是多边形的顶点!所以也可以叫割顶嘛!

好了,接下来把模板题快速过掉!

习题5.4.1

P3388 【模板】割点(割顶)

AC Code: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn=1e5+5; const int maxm=5e5+7; struct edge { int to; int last; }; edge edges[maxm*2]; int head[maxn]; int tot=0; int dfn[maxn]; int low[maxn]; int vis[maxn]; stack<int> s; int idx=0; int iscut[maxn]; int cutnum=0; inline void adde(int u,int v) { edges[++tot].last=head[u]; edges[tot].to=v; head[u]=tot; } void strongtarjan(int x,int root) { s.push(x); dfn[x]=low[x]=++idx; vis[x]=1; int child=0; for(int i=head[x];i!=0;i=edges[i].last) { int v=edges[i].to; if(!dfn[v]) { strongtarjan(v,root); low[x]=min(low[x],low[v]); if(low[v]>=dfn[x]) { child++; if(x!=root||child>1) { cutnum+=!iscut[x]; iscut[x]=1; } } } else if(vis[v]) { low[x]=min(low[x],dfn[v]); } } } int main() { int n=0,m=0,u=0,v=0; cin>>n>>m; for(int i=1;i<=m;i++) { cin>>u>>v; adde(u,v); adde(v,u); } for(int i=1;i<=n;i++) { if(!dfn[i])strongtarjan(i,i); } cout<<cutnum<<endl; for(int i=1;i<=n;i++) { if(iscut[i]==1) { cout<<i; if(i<n)cout<<' '; } } return 0; } ``` 接下来再看割边的性质: 与割点类似的,一个SCC有且只有一条通往外界的边时,这条边才能被称为割边,如果有多条边可以通往外界,那么这个SCC就肯定没有割边。正确性是显然的。 那怎么做呢?我们想到把割边转化成连接割边的点,对于一条边的端点$x$,如果另一个端点$y$最早能到达的点都比$x$晚遍历到,那么$y$肯定是不能通过$\{x,y\}$这条边走到别的SCC去的,那么$\{x,y\}$这条边就可以称之为割边。 那么条件的表达式会不会也是$low[y]\ge dfn[x]$呢?很遗憾,并不是,这里并不能取等。为什么呢?因为我们找割边时并不允许**沿着一条边来回走**,也是,走一次回溯时就可以判掉它行不行了,还走两次有什么用呢? 那我们怎么控制不来回走呢?道理也很简单,我们将边按0-1,2-3,4-5……这样配对,然后就可以发现2^1=3,4^1=5……并且反过来也成立。那么我们只要这么在循环时判断,如果是这样,我们不更新$low[x]$就可以了。 同样的,因为$dfn$和我们不走反边的功劳,割边的求法也是$O(n+m)$的。 接下来是一道典。 ### 习题5.4.2 [P1656 炸铁路](https://www.luogu.com.cn/problem/P1656) 这就是信息学算法的实际应用吧!(bushi) AC Code: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn=1e5+5; const int maxm=5e5+7; struct edge { int to; int last; }; edge edges[maxm*2]; int head[maxn]; int tot=1;//存边0-1,2-3,...配对 int dfn[maxn]; int low[maxn]; int vis[maxn]; stack<int> s; int idx=0; struct bridge { int x; int y; friend bool operator <(bridge a,bridge b) { if(a.x==b.x)return a.y<b.y; else return a.x<b.x; } }; bridge bri[maxm]; int cnt=0; inline void adde(int u,int v) { edges[++tot].last=head[u]; edges[tot].to=v; head[u]=tot; } void strongtarjan(int x,int e) { s.push(x); dfn[x]=low[x]=++idx; vis[x]=1; for(int i=head[x];i!=0;i=edges[i].last) { int v=edges[i].to; if(!dfn[v]) { strongtarjan(v,i); low[x]=min(low[x],low[v]); if(low[v]>dfn[x]) { bri[++cnt]={x,v}; } } else if(i!=(e^1)) { low[x]=min(low[x],dfn[v]); } } } int main() { int n=0,m=0,u=0,v=0; cin>>n>>m; for(int i=1;i<=m;i++) { cin>>u>>v; adde(u,v); adde(v,u); } for(int i=1;i<=n;i++) { if(!dfn[i])strongtarjan(i,0); } sort(bri+1,bri+cnt+1); for(int i=1;i<=cnt;i++) { if(bri[i].x>bri[i].y)swap(bri[i].x,bri[i].y); cout<<bri[i].x<<' '<<bri[i].y; if(i<cnt)cout<<endl; } return 0; } ``` ## 5.5 2-sat $2-sat$问题是一类方案安排问题,问能不能能找到一组合适的bool值,完全满足如下的约束条件组: $$ x_a=0\lor x_b=1\\ x_c=0\lor x_d=1\\ ...... $$ 这里解释一下$\lor$的意思,一般地: $$ a=0\lor b=1\coloneqq (a=0\xRightarrow\space b=0)\land(a=1\xRightarrow\space b=1)\land(a=0\xRightarrow\space b=1) $$ 就是$\lor$表示“**至少**”的意思,左右两边的式子至少满足一个。 因为这里的每个式子只有两个条件,如果一个式子有$k$个条件,我们就称其为$K-SAT$。比较可惜的是,当$k\ge 3$时,这个问题是个NP问题,没有多项式时间的解法(要解决是一般是爆搜)。但$k=2$,也就是$2-SAT$,我们还是有些简便的办法的。 哦,那怎么处理呢?我们想到前面的差分约束,都是满足一堆式子,$2-SAT$能建模成图论问题吗?按照差分约束的经验,难点也多半是在建边。 但是也没那么难,我们有了[种类并查集](https://www.luogu.com.cn/article/3t9qha9r)的启发,马上就可以想到用两个点分别表示某个变量选和不选的情况,为什么不直接说`i`和`i+n`呢?因为有些~~毒瘤~~题目给数据时不是这么定义的QAQ 但是我们这里还是可以简化问题的,我们设`i`为**选$x_i$的情况**,`i+n`为**不选$x_i$的情况**,那么,原展开式中的$\xRightarrow\space$就可以归纳为这些节点互相连边的情况。 我们当然可以丢一个复杂的连边表,但我们说过了$2-SAT$问题会比差分约束简单,所以你可以考虑直接这么加边: ```cpp adde(u+(!uval)*n,v+vval*n);//u,v为读入节点 adde(v+(!vval)*n,u+uval*n);//uval,vval为应取什么值 ``` ~~位运算就是神奇吧~~ 好,接下来我们怎么做呢?我们想到差分约束是靠负环判无解,那我们对于$2-SAT$该怎么做呢?考虑到我们连边的肯定就没有矛盾,如果我们找出一个**强连通分量**,两两连通,那肯定都不会排斥!当然,如果有一个变量的两个值同时在一个强连通分量里,那肯定就无解了,一个变量怎么能同时取两个值呢? 对于每个变量具体取值的问题,我们决定,选择`i`和`i+n`中所在的SCC编号小的。如果`i`小,我们输出`1`,反之就输出`0`。而且不建议边处理边输出,建议扔到一个数组里。 ### 习题5.5.1 [P4782 【模板】2-SAT](https://www.luogu.com.cn/problem/P4782) OK,动手吧! AC Code: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn=2e6+5; const int maxm=3e6+7; struct edge { int to; int last; }; edge edges[maxm*2]; int head[maxn]; int tot=0; int dfn[maxn]; int low[maxn]; int vis[maxn]; stack<int> s; int idx=0; int scccnt=0; int scc[maxn]; int ans[maxn]; inline void adde(int u,int v) { edges[++tot].last=head[u]; edges[tot].to=v; head[u]=tot; } void strongtarjan(int x) { s.push(x); dfn[x]=low[x]=++idx; vis[x]=1; for(int i=head[x];i!=0;i=edges[i].last) { int v=edges[i].to; if(!dfn[v]) { strongtarjan(v); low[x]=min(low[x],low[v]); } else if(vis[v]) { low[x]=min(low[x],dfn[v]); } } if(dfn[x]==low[x]) { scccnt++; int u=0; do { u=s.top(); vis[u]=0; s.pop(); scc[u]=scccnt; } while(x!=u); } } int main() { int n=0,m=0,u=0,v=0,uval=0,vval=0; cin>>n>>m; for(int i=1;i<=m;i++) { cin>>u>>uval>>v>>vval; adde(u+(!uval)*n,v+vval*n); adde(v+(!vval)*n,u+uval*n); } for(int i=1;i<=2*n;i++) { if(!dfn[i])strongtarjan(i); } for(int i=1;i<=n;i++) { if(scc[i]==scc[i+n]) { cout<<"IMPOSSIBLE"; return 0; } ans[i]=(scc[i]>scc[i+n]?1:0); } cout<<"POSSIBLE"<<endl; for(int i=1;i<=n;i++) { cout<<ans[i]; if(i<n)cout<<' '; } return 0; } ``` # 6.判定问题 ## 6.1 欧拉路&&欧拉回路 欧拉路和欧拉回路是$dfs$的一个有力练习。 我们先不加证明地给出欧拉(回)路的定义和判定方法: - 对于无向图: 1. 存在欧拉路的充要条件是只有两个度数为奇数的点,称为奇点 2. 存在欧拉回路的充要条件是所有的点度数都为偶数。 - 对于有向图: 1. 计每条出边对度数贡献为1,每条入边为-1 2. 存在欧拉路的充要条件是有且仅有两个点,一个度数为1,一个度数为-1,遵循“1起-1终” 3. 存在欧拉回路的充要条件是所有点度数都为$0

这下标准明确了,先判掉要不要不可以总司令

如果可以的话,我们来想怎么实现dfs

我们再考虑定义:欧拉路是一笔画,每条边只能走一次,但……点好像没有特殊性质。

但朴素的dfs可是靠点的vis来判断终止的啊!这下真就“停不下来”了!

诶,还有一个办法,既然点没限制,边有限制,那我们就给边打vis吧!

我们定义一个结构体edge,包含边的终点和vis,再把熟悉的vector<int> graph[maxn]换成vector<edge> graph[maxn],这样不就给边打上花火打上vis了吗!good

然后,我们开始dfs,遍历传入点的每条边,如果这条边还没访问过,那我们就打好vis,以它的另一个端点为起始点进行dfs,在全部边都访问完后,我们就可以将起始点输出了。

你以为结束了?no way!你会发现,打印的路径和答案刚好相反?!怎么回事!

你是dfs啊,你在所有边都搜完后才把点保存输出啊!你试试,按“1起-1终”的规则,结果你的起始点是不是最后打印?要不得啦!

那咋办咧?你开个栈,把输出换成入栈不就行了吗!

dfs()全部执行完后,再打一个弹一个,OKAY。

理论上有向图和无向图的欧拉dfs()只有建图的区别,你写写下面两道题,确实难点基本上只在处理度数。

习题6.1.1

P7771 【模板】欧拉路径

本题为div.有向图。

这里解释一下,本题的euler()习题6.1.2的有一些不相同。

这是因为加了一些优化。

讨论区把这种优化称为“当前弧优化”。

具体来说,我们在遍历一个点的每条边时,如果还要一条一条查边的vis,有点费时间,我们可以空间换时间,开一个now数组,然后每给一条边打vis,在递归之前,我们就给起始点的now置为当前边的编号+1,循环时i直接置为now[起始点],然后也别机械i++了,直接让i+1now[起始点]打擂台,给i赋最大值。

这样就跳掉了已经打vis的边,连if都不跑了,你就说快不快吧doge。如果不这么写,You will get only \color{#E74C3C}{90pts} \color{#052242}{with} \color{#052242}{testcase10TLE},写了后,那个点直接掉到<100ms,让我感觉出了个专门卡优化的点,但是我没有证据。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;
const int maxm=2e5+7;

struct edge
{
    int to;
    int vis;

    friend bool operator <(edge a,edge b)
    {
        return a.to<b.to;
    }
};
vector<edge>graph[maxn];
int degree[maxn];
int s=0;
int t=0;
stack<int>ans;
int now[maxn];

inline void adde(int u,int v)
{
    graph[u].push_back({v,0});
    degree[u]++;
    degree[v]--;
}

void euler(int x)
{
    for(int i=now[x];i<graph[x].size();i=max(i+1,now[x]))
    {
        if(!(graph[x][i].vis))
        {
            graph[x][i].vis=1;
            now[x]=i+1;
            euler(graph[x][i].to);
        }
    }
    ans.push(x);
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n=0,m=0,u=0,v=0,cnt=0;
    cin>>n>>m;
    for(int i=1;i<=m;i++)
    {
        cin>>u>>v;
        adde(u,v);
    }
    for(int i=1;i<=n;i++)
    {
        if(degree[i]==1)
        {
            if(s!=0)
            {
                cout<<"No";
                return 0;
            }
            s=i;
        }
        else if(degree[i]==-1)
        {
            if(t!=0)
            {
                cout<<"No";
                return 0;
            }
            t=i;
        }
        else if(degree[i]==0)
        {
            cnt++;
        }
        else
        {
            cout<<"No";
            return 0;
        }
    }
    for(int i=1;i<=n;i++)
    {
        sort(graph[i].begin(),graph[i].end());
    }
    if(cnt==n)euler(1);
    else euler(s);
    while(!ans.empty())
    {
        int buf=ans.top();
        ans.pop();
        cout<<buf;
        if(!ans.empty())cout<<' ';
    }
    return 0;
}

习题6.1.2

P2731 [USACO3.3]骑马过栅栏 Riding the Fences

本题为div.无向图。

你说优化为什么没有?因为它数据规模小[doge]

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=505;
const int maxm=1e4+7;

int graph[maxn][maxn];
int degree[maxn];
int anss=0;
int anst=0;
vector<int>ans;

inline void adde(int u,int v)
{
    graph[u][v]++;
}

void euler(int len,int s)
{
    for(int i=1;i<=len;i++)
    {
        if(graph[s][i]==0)continue;
        graph[s][i]--;
        graph[i][s]--;
        euler(len,i);
    }
    ans.push_back(s);
}

int main()
{
    int m=0,u=0,v=0,cnt=0,num=0;
    cin>>m;
    for(int i=1;i<=m;i++)
    {
        cin>>u>>v;
        adde(u,v);
        adde(v,u);
        num=max(num,max(u,v));
        degree[u]++;
        degree[v]++;
    }
    int s=1;
    for(int i=1;i<=num;i++)
    {
        if(degree[i]%2==1)
        {
            cnt++;
            s=i;
            break;
        }
    }
    if(cnt!=0)euler(num,s);
    else
    {
        for(int i=1;i<=num;i++)
        {
            if(degree[i]!=0)
            {
                euler(num,i);
                break;
            }
        }
    }
    for(int i=ans.size()-1;i>=0;i--)
    {
        cout<<ans[i]<<endl;
    }
    return 0;
}

再强调一声,欧拉图要求图要连通!!!

如果图不连通,你应当输出orz——P3366【模板】最小生成树

开个玩笑啊,你别每题都只记得输出orz了[doge]。

6.2 二分图简介

二分图也是个变化多端的知识点,虽然母题模型不多,但是别的奇葩量有一大堆。限于目标要求,本文主要介绍二分图的判定和二分图最大匹配。

首先来了解二分图的定义,如果能把一个图的点划分为两个集合,集合内部的点两两没有边相连,那这就是个二分图。

首先是一个显然的性质,一个图是二分图的充要条件是其不含奇环。因为集合内部没有边,肯定要走偶数次才能回到自己的集合,你可以手画一个。

有了这个性质,我们就可以开始判定了。

还是万能的dfs,怎么把“集合内部没有边”加进去呢

一个普遍的方法是染色,我们把遍历到的节点交替染色,如果搜到的节点颜色重合了,那就染不成了,因为边都是连接两个不同集合,如果有一条边两端颜色相同,那显然不行。

而且,我们还要把“不行”传递上来,所以,如果判断到回溯时产生“不行”的信号,那本层也是返回“不行”,不行就要不行到底,中途可以怎么行?

好,切模板题时间。

习题6.2.1

P1330 封锁阳光大学

你们也喜欢用NOIP2010T1当二分图判定例题,但我已经把它划给并查集了,为了提交数,我们尽量不重题哈。doge

因为二分图并没有要求图是连通的(不连通也可以划到一个集合里嘛),所以我们要以每个点为起点,都搜一遍,把答案累加,才能输出。

这个坑点竟然没有hack,我要造一组。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e4+5;
const int maxm=1e5+7;

struct edge
{
    int to;
    int last;
};
edge edges[maxm*2];
int head[maxn];
int tot=0;
int vis[maxn];
int ans[3];
int res=0;

inline void adde(int a,int b)
{
    edges[++tot].to=b;
    edges[tot].last=head[a];
    head[a]=tot;
}

bool dfs(int s,int op)
{
    ans[op]++;
    vis[s]=op;
    for(int i=head[s];i!=0;i=edges[i].last)
    {
        int v=edges[i].to;
        if(!vis[v])
        {
            if(dfs(v,3-op)==false)return false;
        }
        else if(vis[v]==op)return false;
    }
    return true;
}

int main()
{
    int n=0,m=0,u=0,v=0;
    cin>>n>>m;
    for(int i=1;i<=m;i++)
    {
        cin>>u>>v;
        adde(u,v);
        adde(v,u);
    }
    for(int i=1;i<=n;i++)
    {
        if(!vis[i])
        {
            memset(ans,0,sizeof(ans));
            if(dfs(i,1)==false)
            {
                cout<<"Impossible";
                return 0;
            }
            res+=min(ans[1],ans[2]);
        }
    }
    cout<<res;
    return 0;
}

接下来学习二分图的一个著名问题:最大匹配。当然它还有完美匹配和最大权匹配几个变种,并且匹配的概念也可以推广到一般图上,但我们不管那么多了(

所谓二分图最大匹配,就是问你在二分图的两个部分中间能有多少对点配对。所谓配对,也就是直接的有边相连,只是说由于题目给的逗霸连边,很有可能有多种配对方案,每种配对方案能凑出几个配对,我们称为配对数是几。最大匹配要求的就是配对数最大的一种方案。

关于这个问题有两种算法,但是根据我们的懒惰目标要求,我们只学一种“匈牙利算法”。我绝对不会告诉你另一种算法对网络流的衔接更有帮助

你们可能都听过所谓“Re:最大匹配之网络流の降维打击”但我觉得O(n\sqrt m)的复杂度吸引力不是很大,所以我们讲网络流时你可以自己想想。

接下来我们学习匈牙利算法,它基本上是对一边的点每个点跑一遍dfs()。但这个dfs()有一定的改编,而且递归中的改参数过程异常毒瘤让我想起了treap

我们想想,按照最初的定义,我们该怎么求最大匹配。一个很显然的想法是对于同一部分的每个点,逐个扫所有边,如果选择这条边那一头的端点没事,那就连呗;如果让出这个点去跟别的点配对能够让其他同部分点配对,那就相当于配对数可以增加,对答案有增益,我们当然要这么干。

我们考虑对匹配的那个部分(扫边的)建一个match[maxn]数组,表示另一部分中的每个点应该在这一部分中找哪个点配对,这里我们将两部分的点分开编号(即都是1~n)。

我们人为规定从左部分扫描边,匹配右部分。

然后,因为有dfs()成分,所以我们先写一个dfs(),然后也写个vis。对左部分的每个点,我们跑次dfs()。但是vis我们打的是表示右部分的点。

dfs()内,对于每个右端点,我们能将它匹配有两个条件:

这两个条件必须都满足。这时,我们给这个右端点打上vis,但是还不能最终确定!因为可能牺牲这对配对会有更多的配对!难绷。

那我们再思考一下,什么情况能够确定匹配呢?

肯定嘛,对方有别的对象了,肯定是匹配方重新找嘛(bushi)

显然这两个条件有一个能满足,我们就可以将传入左端点和这个右端点配对了喜大普奔,发个invitationtrue回去。

如果右部分所有点都遍历完了,传入左端点依然没有配对的点,那就只能扔个false回去了(

这样我们的dfs()就大功告成了!匈牙利算法的主函数只要对左部分的每个点都跑遍dfs(),有一个点成功就rp++就可以了!还是很简单的!(bushi)

顺带一提,我们在每个点dfs()走过的路径叫做增广路,二分图求匹配相关问题的过程,也就是不断找增广路的过程。

匈牙利算法的复杂度为O(nm)n为两个部分的点数和,m为总边数。

最后还有一个概念:假设选一个点就代表“覆盖”了这个点和所有以这个点为端点的边,我们也很关心最少选几个点,能够覆盖所有的边,这个问题就是最小点覆盖问题

对于这个问题,我们不加证明地给出\mathrm{k\ddot{o}nig}定理:

最小点覆盖数=最大匹配数

刷题时会用到。

习题6.2.2

P3386 【模板】二分图最大匹配

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxm=5e2+5;
const int maxn=5e2+5;
const int maxe=5e4+7;

int graph[maxn][maxm];
int vis[maxn];
int match[maxn];
int cnt;

bool dfs(int n,int len)
{
    for(int i=1;i<=len;i++)
    {
        if(graph[n][i]&&!vis[i])
        {
            vis[i]++;
            if(match[i]==0||dfs(match[i],len))
            {
                match[i]=n;
                return true;
            }
        }
    }
    return false;
}

int hagarian(int left,int right)
{
    int res=0;
    for(int i=1;i<=left;i++)
    {
        memset(vis,0,sizeof(vis));
        if(dfs(i,right))++res;
    }
    return res;
}

int main()
{
    int n=0,m=0,e=0;
    cin>>n>>m>>e;
    for(int i=1;i<=e;i++)
    {
        int a=0,b=0;
        cin>>a>>b;
        graph[a][b]=1;
    }
    if(n==1&&m==1&&e!=0)
    {
        cout<<1;
        return 0;
    }
    int res=hagarian(n,m);
    cout<<res;
    return 0;
}

2024/8/28 20:25:00 初稿!完结撒花!