無向圖的連通性
最近又複習了一遍tarjan,發現無向圖的連通性比有向圖複雜不少,決定寫一篇部落格總結一下。
總體來說,
跟邊有關的是橋,然後就有邊雙連通分量;
跟點有關的是割點(割頂),然後就有點雙連通分量。
接下來將詳細的講每一個知識點。
橋
如果刪除了一條邊後,整個圖被分裂成了兩個不相連的子圖,那麼這條邊稱為橋。
橋的判定方法很簡單:對於在dfs樹上的節點\(u\),如果他的一個兒子\(v\)滿足\(low[v] > dfn[u]\),那麼邊\((u,v)\)就是一座橋。
直觀的理解就是\(v\)及其子樹的任何一個節點都到不了\(u\)或者更往上的點。
接下來是程式碼實現的一些細節:
因為是無向圖,所以對於每一條無向邊,我們用兩條有向邊代替,那麼如果\(u\)
因為有些題可能有重邊,所以在dfs時記錄父親節點編號是會出錯的。
主要程式碼:
int dfn[maxn], low[maxn], cnt = 0; In void tarjan(int now, int _e) { dfn[now] = low[now] = ++cnt; forE(i, now, v) { if(!dfn[v]) { tarjan(v, i); low[now] = min(low[now], low[v]); if(dfn[now] < low[v]) bridge[i] = bridge[i ^ 1] = 1; } else if(i ^ (_e ^ 1)) low[now] = min(low[now], dfn[v]); } }
邊雙連通分量(e-DCC)
如果一個子圖裡不含橋,那這個子圖就是一個邊雙連通分量。
邊雙聯通分量的計算方法很簡單,只要刪除圖中的所有橋就行了。
這個可以用上面的程式碼再加上一個dfs實現,我們只用判斷當前的邊是否是橋即可,不是的話繼續dfs。
不過這樣寫未免有些冗餘,我們可以仿照有向圖強連通分量的做法,在第一遍dfs的時候開一個棧維護。
實際上他和強連通分量縮點非常像,如果點\(u\)滿足\(dfn[u]==low[u]\),那麼就一直彈棧知道把\(u\)彈出去為止,則這些點就是一個e-DCC。
int dfn[maxn], low[maxn], cnt = 0; int st[maxn], top = 0; int col[maxn], du[maxn], ccol = 0; In void tarjan(int now, int _e) { dfn[now] = low[now] = ++cnt; st[++top] = now; forE(i, now, v) { if(!dfn[v]) { tarjan(v, i); low[now] = min(low[now], low[v]); } else if(i ^ (_e ^ 1)) low[now] = min(low[now], dfn[v]); } if(dfn[now] == low[now]) { int x; ++ccol; do { x = st[top--]; col[x] = ccol; //點x屬於編號為ccol的e-DCC }while(x ^ now); } }
至於e-DCC縮點,因為我們已經求出每一個點所屬的e-DCC了,所以只要遍歷每一個點的出邊就能建立縮點之後的新圖了。
割點
如果刪除了點\(u\)及其相連的邊後,這張圖不連通,那麼點\(u\)就成為割點,或者割頂。
割點的判定法則是:
如果點\(u\)不是dfs的樹的根節點,那麼對於\(u\)的兒子\(v\),如果\(dfn[u] \leqslant low[v]\),那麼\(u\)就是割點;
如果\(u\)是根節點,需要有至少兩個\(v\)滿足上述條件。
理解起來就是\(v\)及其子樹中的所有節點最高只能爬到\(u\),一點把\(u\)刪去,\(v\)的子樹就不和\(u\)的祖先連通了;而對於\(u\)是根節點的情況,如果只存在一個\(v\)滿足條件,那麼刪除\(u\)後剩下的圖是\(v\)及其子樹,仍然連通,所以至少要需要兩個這樣的節點\(v_1,v_2\),這樣刪除\(u\)後才存在兩個不連通的子圖\(v_1\),\(v_2\)。
因為判定法則是小於等於號,所以反向邊的父親節點和重邊並不會對結果產生影響,從\(u\)出發的所有點的時間戳都可以用來更新\(low[u]\)。
int dfn[maxn], low[maxn], cnt = 0;
int cut[maxn], root;
In void tarjan(int now)
{
dfn[now] = low[now] = ++cnt;
int flg = 0;
forE(i, now, v)
{
if(!dfn[v])
{
tarjan(v);
low[now] = min(low[now], low[v]);
if(low[v] >= dfn[now])
{
++flg;
if(now != root || flg > 1) cut[now] = 1;
}
}
else low[now] = min(low[now], dfn[v]);
}
}
//以下程式碼在主函式中
for(int i = 1; i <= n; ++i) if(!dfn[i]) root = i, tarjan(i);
點雙連通分量(v-DCC)
一個點雙連通分量的定義是,刪去這個v-DCC中的任意一個點及其連邊,該v-DCC中剩餘的仍然連通。
特別的,對於孤立點,他自己構成一個v-DCC。除了孤立點之外,v-DCC的大小至少為2.
為什麼不能用上面和橋類似的定義呢?因為一個割點可能屬於多個v-DCC,這也導致點雙的求法有些不同。
先解決一個小問題,求一張圖刪除一個點後,最多有多少個連通分量。
顯然是要刪除割點,所以我們現在要做的是求出刪除一個割點\(u\)後形成的連通分量個數\(dp[u]\)。
其實就是在dfs的時候,如果\(dfn[u] \leqslant low[v]\),那麼\(u\)和\(v\)及其子樹就形成了一個v-DCC,\(dp[u]\)++即可。
最後當\(u\)回溯的時候判斷\(u\)是否是根節點,不是的話\(dp[u]\)++,因為還有\(u\)的祖先所在的v-DCC。
這道題啟發我們求v-DCC的時候不能等\(u\)回溯的時候再開始彈棧處理\(u\)所在的v-DCC,而是要在\(dfn[u] \leqslant low[v]\)成立的時候馬上執行。
而且這個彈棧的終止條件是一直彈到\(v\),而不是\(u\),因為棧裡面在\(u\)和\(v\)之間還有\(u\)在\(v\)之前遍歷的子樹(而且這些子樹一定不滿足\(dfn[u] \leqslant low[v']\),否則就被彈出去了)。彈出\(v\)後,再把\(u\)加入到該v-DCC中即可。
int dfn[maxn], low[maxn], cnt = 0;
int st[maxn], top = 0;
vector<int> dcc[maxn];
int cut[maxn], root, ccol = 0;
In void tarjan(int now)
{
dfn[now] = low[now] = ++cnt;
st[++top] = now;
if(x == root && head[x] == -1) {dcc[++ccol].push_back(x); return;} //孤立點
int flg = 0;
forE(i, now, v)
{
if(!dfn[v])
{
tarjan(v);
low[now] = min(low[now], low[v]);
if(low[v] >= dfn[now])
{
++flg;
if(now != root || flg > 1) cut[now] = 1;
int x; ++ccol;
do
{
x = st[top--];
dcc[ccol].push_back(x);
}while(x ^ v);
dcc[ccol].push_back(now);
}
}
else low[now] = min(low[now], dfn[v]);
}
}
但這樣寫有一個弊端:無法在保證時間複雜度的前提下確定每一個v-DCC中有幾條邊。
因為我們只能重新遍歷每一個v-DCC中的每一個點的所有出邊,判斷有沒有兩個端點都在該v-DCC中。但因為一個割點可能在多個v-DCC中,所以我們只要構造一個菊花圖,這樣中間的點就是割點,且在\(n-1\)個v-DCC中,那麼遍歷邊的複雜度就是\(O(n^2)\)的了。
為了解決這個問題,改成往棧裡存邊。因為點雖然可能在多個v-DCC中,但是邊一定是在一個v-DCC中的。
這樣彈棧的時候只用把不在當前v-DCC的節點加進去即可,這個可以用一個set維護,如果不想多一個log的話,開一個數組記錄這個點所屬的v-DCC編號,如果不是當前的v-DCC,就把他加進去,並把這個點所屬的v-DCC編號改過來。
關於往棧里加邊,除了樹邊,還有滿足\(dfn[v] < dfn[u]\)的邊,否則存的邊可能屬於是已經算過的v-DCC,導致存的邊比實際上的多。
int dfn[maxn], low[maxn], cnt = 0, top = 0, ccol = 0;
struct Node{int x, y;}st[maxn];
vector<int> dcc[maxn];
In void tarjan(int now, int _e)
{
dfn[now] = low[now] = ++cnt;
forE(i, now, v)
{
if(!dfn[v])
{
st[++top] = (Node){now, v};
tarjan(v, i);
low[now] = min(low[now], low[v]);
if(low[v] >= dfn[now])
{
Node tp; ++ccol;
dcc.clear();
do
{
tp = st[top--];
if(col[tp.x] ^ ccol) dcc[col[tp.x] = ccol].push_back(tp.x);
if(col[tp.y] ^ ccol) dcc[col[tp.y] = ccol].push_back(tp.y);
}while(tp.x != now || tp.y != v);
}
}
else if(i ^ (_e ^ 1))
{
low[now] = min(low[now], dfn[v]);
if(dfn[v] < dfn[now]) st[++top] = (Node){now, v};
}
}
}
綜合來看,第二種寫法在功能上確實比第一種要好的多,也是網上大多數的寫法,但同時也更難理解,所以如果不用統計內部邊數的話,第一種寫法當然是價效比更高的了。
關於v-DCC縮點,要比e-DCC縮點複雜一些,而且建圖方式不一樣。
思路是把每一個v-DCC和割點看成新的節點,並在每個割點與包含他的所有v-DCC之間連邊。
做法是在上述求出v-DCC的集合後,給每一個割點分配一個新的編號,並連邊。
以下給出建圖部分的程式碼:
num = ccol;
//給每一個割點x分配一個新的編號nid[x]
for(int i = 1; i <= n; ++i) if(cut[i]) nid[i] = ++num;
for(int i = 1; i <= ccol; ++i)
for(int j = 0; j < (int)dcc[i].size(); ++j)
{
int x = dcc[i][j];
if(cut[x]) addEdge(i, nid[x]), addEdge(nid[x], i);
}
話說v-DCC縮點的題我似乎還沒做過誒……都是求完v-DCC後不用建圖的那種……
關於無向圖的連通性大概就是這些吧,可能有寫的不嚴謹的地方,歡迎指正。