從寒假一開始,到現在也學習了兩個多星期的圖論中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 張小豪