强连通分支及其应用(2-SAT)总结
从寒假一开始,到现在也学习了两个多星期的图论中dfs的相关算法,也做了一些题目。在这里先把强连通分支及其应用做一个第一阶段总结,巩固一下也便于开始下一步学习。在这里我也会列出我总结的一套模版。
首先我们要明确下面的这些算法都是针对有向图而言的,先笼统的说一下强连通分支是什么?其实就是有向图中的一部分,在这部分里任意两个节点都相互可达。虽然表述可能不规范,但是应该比较形象吧。
一、强连通分支(scc)
接下来我们先来学习一下如何在一张给定的图中求出强连通分支,我们需要介绍两个算法:
(1)Kosaraju算法
这个算法的思想很简单,也比较好写。前提是我们已经熟练掌握了dfs的写法及思想。下面是算法流程:
- 首先我们对原图先进性一边dfs得到原图中各结点的拓扑序把他存在一个数组里。
- 在有了拓扑序后,我们再对原图反向后的图按照逆拓扑序进行dfs每次dfs就得到一个强连通分支。
整个算法就描述完了看起来很简单吧,接下来我们说一下具体到程序中我们该如何实现。
- 准备 有上面两步我们看到不仅需要原图我们还需要原图中所有边都反向的图,所以我们在处理输入时必须同时得到Map,rMap。
- 初始化 很简单,vis数组置零(dfs标记用),vs数组清空(记录拓扑序用)。
- 开始第一遍dfs 没遍历一个节点在回溯时将其加入拓扑序数组。
- 开始第二遍dfs 记得之前清空vis数组。并且这次我们要在参数中加入一项就是f标记dfs到的结点是属于那个强连通分支的(记录在sccn数组中)。
下面是我的模版:
1 /**************************************** 2 强连通分支 kosaraju算法 3 By 小豪 4 *****************************************/ 5 const int LEN = 10000+10; 6 vector<int> Map[LEN], rMap[LEN], vs; 7 int vis[LEN], sccn[LEN], n, m; 8 9 void dfs(int v) 10 { 11 vis[v] = 1; 12 for(int i=0; i<Map[v].size(); i++) 13 if(!vis[Map[v][i]])dfs(Map[v][i]); 14 vs.PB(v); 15 } 16 17 void rdfs(int v, int k) 18 { 19 vis[v] = 1; 20 sccn[v] = k; 21 for(int i=0; i<rMap[v].size(); i++) 22 if(!vis[rMap[v][i]])rdfs(rMap[v][i], k); 23 } 24 25 int scc() 26 { 27 memset(vis, 0, sizeof vis); 28 vs.clear(); 29 for(int i=1; i<=n; i++) if(!vis[i])dfs(i); 30 memset(vis, 0, sizeof vis); 31 int k = 0; 32 for(int i=vs.size()-1; i>=0; i--) if(!vis[vs[i]]) rdfs(vs[i], k++); 33 return k; 34 }
算法复杂度是线性的, 返回的k记录了有几个强连通分支。sccn记录了强连通分支的拓扑序(这很有用)。
(2)Tarjan算法
tarjan是一个神奇的人,他提出了许多算法,而这个只是其中的一种。tarjan的复杂度也是线性的,而且比上一种更快因为他只要dfs一遍。tarjan是通过在搜索树中发现第一个属于这个强连通分支的结点,然后由于只要和这个结点在一个强连通分支内的一定是他的后代,那么如何判断一点是不是强连通分支内第一个被发现的点呢?
这个问题我们又要用到了low值与反向边(和割点割边的求法都非常类似)我们在整个搜索过程中设置一个dclock(称为时间戳)每搜过一个点就加一,这样我们就能在确认搜索树中祖先与孩子的关系。比较明显最先搜到的一定是根节点dfn(搜索标号)值为1,稍微想一下就可发现dfn越小的节点越靠近根。
那么low值又是什么?简单来说就是这个点通过反向边能连到dfn最小的点(换句话说就是最靠近根节点的点)这样若是一个点u通过他的孩子节点v能连会(注意这里的连回指的是只通过自己强连通分量内的点)u的祖先x,那么我们就可以确认u,v,x在一个强连通分支内。若是u通过v最多只能连回自己那么我们就能知道u是我们第一个找到的点,在回溯是我们就要把栈中结点取出(并不是全取出具体参考代码)记录为同一个强连通分支。
接线来问题又来了,我们如何得到一个节点dfn和low的值。先说dfn吧,很简单只要在dfs开始让他等于时间戳就ok。然后是low首先我们在dfs重要判断接下来要走的便是属于树边还是反向边,若是树边则说明是孩子节点,那必须用孩子节点的low值来更新当前节点的low值。若是反向边,那说明是祖先,那只要用祖先节点的dfn值来更新当前节点的low值。这样low值就搞定的。
说了那么多下面看代码:
1 /**************************************** 2 强连通分支 tarjan算法 3 By 小豪 4 *****************************************/ 5 const int LEN = 100000 + 10; 6 vector<int> Map[LEN]; 7 int dfn[LEN], low[LEN], dclock, scc_cnt, sccn[LEN], n, m; 8 stack<int> s; 9 10 void sccinit(){ 11 for(int i=0; i<LEN; i++) Map[i].clear(); 12 while(!s.empty())s.pop(); 13 dclock = scc_cnt = 0; 14 memset(sccn, 0, sizeof sccn); 15 memset(dfn, 0, sizeof dfn); 16 } 17 18 void dfs(int u){ 19 dfn[u] = low[u] = ++dclock; 20 s.push(u); 21 for(int i=0; i<Map[u].size(); i++){ 22 int v = Map[u][i]; 23 if(!dfn[v]){ 24 dfs(v); 25 low[u] = min(low[u], low[v]); 26 }else if(!sccn[v]) low[u] = min(low[u], dfn[v]); 27 } 28 if(low[u] == dfn[u]){ 29 scc_cnt++; 30 while(1){ 31 int x = s.top();s.pop(); 32 sccn[x] = scc_cnt; 33 if(x == u) break; 34 } 35 } 36 }
两端代码长度是不相上下的都不长,在tarjan中我们新增了scc_cnt来作为全局变量记录强连通分支的个数。
二、2-SAT
强联通分量一个很重要的用途就是解布尔方程可满足性问题(SAT)。需要学习这一部分知识我们需要一点布尔代数的知识。
下文中我们约定(^表示交v表示并)
例如:(a v b v …)^(c v d v …)^…
这样的我们叫做合取范式。其中(a v b v …)这样的叫做子句。类似a,b...叫做文字。
我们把合取范式中一个子句中包含文字不超过两个的问题成为2-SAT问题。在SAT问题中只有这一类我们可以用线性时间内得出答案。
最常规的2-SAT题目分为大致两种,一种是让你判断有没有解,另一种是让你输出一组解。针对这两种给出模版。
在这之前先来介绍一下2-SAT题目的大致解题步骤:
对于2-SAT问题我们需要构建一张有向图每个文字拆为两个节点 例如 a 变为 a, !a
首先我们从题目中总结出来的都是一些比较杂乱的逻辑表达式,不过一般都是两两之间的关系,我们需要做的第一步是化简成用^连接。然后对于每个子句建边。
建边的规则是这样的 a -> b那么在有向图中建一条a到b的边
我们可能得到的子句有:
a v b 我们可以化简 !a->b ^ !b->a
a -> b 直接连边
a 转化为!a -> a
其中每个文字及其的非对应相应的结点,若是出现在文字前有非的关系例如 !a v b 那么变通一下 就化成 a -> b ^ !b -> !a就可以了。
到这里我们要做的事(建图)就完成了,接下来交给模版,我们来看一下模版做了什么:
首先我们对建完的有向图求强连通分支,若是出现有一个逻辑变量和他的反在同一个联通分之内就无解,否则有解。
若a所在的强连通分支的拓扑序在!a之后a为真,否则为反。怎么样很简单吧。
下面贴出代码:
1 /**************************************** 2 2-SAT kosaraju算法 3 By 小豪 4 *****************************************/ 5 const int LEN = 200000+10; 6 vector<int> Map[LEN], rMap[LEN], vs; 7 int n, m, vis[LEN], sccn[LEN]; 8 9 void dfs(int v){ 10 vis[v] = 1; 11 for(int i=0; i<Map[v].size(); i++) 12 if(!vis[Map[v][i]]) dfs(Map[v][i]); 13 vs.PB(v); 14 } 15 16 void rdfs(int v, int f){ 17 vis[v] = 1; 18 sccn[v] = f; 19 for(int i=0; i<rMap[v].size(); i++) 20 if(!vis[rMap[v][i]]) rdfs(rMap[v][i], f); 21 } 22 23 int scc(){ 24 memset(vis, 0, sizeof vis); 25 vs.clear(); 26 for(int i=0; i<2*n; i++) if(!vis[i]) dfs(i); 27 memset(vis, 0, sizeof vis); 28 int k = 0; 29 for(int i = vs.size()-1; i>=0; i--) if(!vis[vs[i]]) rdfs(vs[i], k++); 30 return k; 31 } 32 33 void addedge(int a, int b){ 34 Map[a].PB(b); 35 rMap[b].PB(a); 36 } 37 38 void solve() 39 { 40 scc(); 41 for(int i=0; i<2*n; i+=2) 42 if(sccn[i] == sccn[i+1]){ 43 //printf("No solution.\n"); 44 //无解 45 return ; 46 } 47 for(int i=0; i<n; i++){ 48 if(sccn[i*2] > sccn[i*2+1]) printf("Yes\n"); 49 else printf("No\n"); 50 } 51 }
好了主体部分讲完了,接下来在讲一下再强连通的题目中,我们往往会用到缩点(就是把同一个强连通分支内的点缩成一个),其实缩点并不是都要把几个点缩成一个,需要根据题目的需要,有时候只需判断一下就可以了。在缩点后强连通分支内的点往往具有相同的特性,就赋予这个点一个新的意义。而且原图也变成了DAG,就可以dp等等。
水平有限,只希望把自己所知道的和大家分享一下。若大神发现有什么错误,欢迎留言指正,定当感激不尽! By 张小豪