1. 程式人生 > 實用技巧 >tarjan 演算法與圖的連通性

tarjan 演算法與圖的連通性

前言與預備知識

發現我根本不會 tarjan,又發現《演算法競賽進階指南》上正好有相關講解,於是回來補 tarjan 這個 NOIP 演算法。 (順便頹一會兒水題)

首先我們要知道 搜尋樹 的相關內容(注意區分搜尋樹和原圖):

定義 \(dfn[cur]\)\(cur\) 節點的時間戳。

\(low[cur]\)\(cur\) 節點的追溯值。

其中 \(low[cur]\) = \(min\){搜尋樹中 \(cur\) 的子樹的節點的時間戳, 子樹中通過一條邊,能夠到達的節點的時間戳}

警告:分清 \(dfn\)\(low\)

無向圖相關問題

橋 與 邊雙連通分量

無向圖中,如果割掉一條邊,可以使整個無向圖成為兩個連通塊,那麼這條邊成為割邊

判定法則:

\[low[to] > dfn[cur] \]

顯然,橋一定是搜尋樹上的邊簡單環中的邊一定不是橋P2607 [ZJOI2008]騎士 中找環的方法之一)。

注意:不要用 \(cur\)\(low/dfn\) 來更新 \(fa\)\(low\)。但是可能會遇到重邊等問題,所以記錄入邊編號 \(ine\),防止遍歷 \(ine\)

求法:

//(initial)ecnt = 1
void tarjan(int cur, int ine) {
	dfn[cur] = low[cur] = ++dtot;
	for (register int i = head[cur]; i; i = e[i].nxt) {
		int to = e[i].to;
		if (!dfn[to]) {
			tarjan(to, i);
			low[cur] = min(low[cur], low[to]);
			if (low[to] > dfn[cur]) 
				iscut[i] = iscur[i ^ 1] = true;
		} else if (i != ine ^ 1)
			low[cur] = min(low[cur], dfn[to]);
	}
}

邊雙連通分量(e-DCC)

不存在割邊的無向連通圖為 邊雙連通圖。極大邊雙連通子圖為 邊雙連通分量

一張無向連通圖是邊雙連通圖,當且僅當對於圖中每條邊,都在至少一個簡單環上

  • 求法:

將橋刪去後,整個圖就成了一個個邊雙連通分量。

可以對圖進行縮點。點內無橋,點間為橋。這樣的話,縮點後沒有任何環,是無向圖森林

void tarjan(int cur, int ine);
bool vis[N];
int siz[N];
int col[N], ctot;
void Dfs(int cur) {
	vis[cur] = true;
	col[cur] = ctot;
	siz[ctot]++;
	for (register int i = head[cur]; i; i = e[i].nxt) {
		int to = e[i].to;
		if (vis[to] || iscut[i])	continue;
		Dfs(to);
	}
}
vector<int> eg[N];
inline void adeg(int u, int v) {
	eg[u].push_back(v); eg[v].push_back(u);
}

//in main()
for (register int i = 1; i <= n; ++i) {
	if (!dfn[i])	tarjan(i, 0);
}
memset(vis, 0, sizeof(vis));
for (register int i = 1; i <= n; ++i) {
	if (!vis[i])	ctot++, Dfs(i);
}
for (register int i = 2; i <= ecnt; i += 2) {
	if (iscut[i]) {
		adeg(col[e[i].to], col[e[i ^ 1].to]);
	}
}
  • 典型應用:無向圖的必經邊

必經邊 = 割掉後點對不連通 = 點對間的割邊(橋)

邊雙縮點+樹剖,查詢點對距離即可。

題意:

n 個點,m 條邊, q 次詢問,每次問 \(i\) 號邊刪去後會有多少點對互不可達。

n <= 1e5, m <= 1e6, q <= 8e5

發現不刪也會有一堆點對互不可達(原本不一定聯通)。如果刪去的是割邊,那麼會增加其兩端連通塊的大小之積這麼多的點對。

因此首先並查集搞出“初始答案”。然後 \(tarjan\) 求割邊。然後將割邊刪去,進行縮點。此時縮好以後的圖是一棵森林。因此我們可以對每棵樹類似鏈剖的第一個dfs一樣地搞出 \(dep\)\(siz\)。沒了。

注意區分“節點”與“節點”之間的關係。即區分:

森林--樹(連通塊)--樹上的點(邊雙連通分量)--原圖的點

割點 與 點雙連通分量

  • 割點

刪去點及其所有連邊後,原無向圖分裂成為多個連通塊,則這一點為 割點

判定法則:

\[low[to] >= dfn[cur] \]

具體來說,對於(搜尋樹的)非根節點 \(cur\),如果存在 \(low[to] >= dfn[cur]\),那麼 \(cur\) 為割點;對於根節點來說,如果有 至少兩個 \(to\) 符合條件,那麼根節點也為割點。

由於是 \(>=\),因此就算拿 \(fa\)\(dfn\) 來更新點的 \(low\)(顯然不可能用 \(fa\)\(low\) 來更新),也無法使該點跳出包圍圈,不會將其 \(fa\) 誤判為非割點。

求法:

模板提交處

void tarjan(int cur) {
	dfn[cur] = low[cur] = ++dcnt;
	int cnt = 0;
	for (register int i = head[cur]; i; i = e[i].nxt) {
		int to = e[i].to;
		if (!dfn[to]) {
			tarjan(to);
			low[cur] = min(low[cur], low[to]);
			if (low[to] >= dfn[cur]) {
				cnt++;
				if (!iscut[cur] && (cur != rt || cnt > 1))
					cut[++cuttot] = cur, iscut[cur] = true;
			}
		} else {
			low[cur] = min(low[cur], dfn[to]);
		}
	}
}

題意 : 給定一張無向聯通圖,求每個點被封鎖(刪去與該點相連的所有邊)之後有多少個有序點對(x,y)(x!=y,1<=x,y<=n)滿足x無法到達y

注意:沒有刪去那個點(不過都是細節了)

如果刪去的不是割點,那麼圖仍然是聯通圖(除了單拎出來一個點),直接特判即可。

如果刪去的是割點,那麼圖將會四分五裂。準確地說,如果對於這個點 \(cur\) 來說有 \(T\)\(to\) 符合 \(low[to] >= dfn[cur]\) 的條件,那麼這張圖最多能分成 \(T + 2\) 個連通塊,包括那麼多 \(to\) 的子樹 + \(cur\)節點 + 除此以外的所有點(這個可能沒有)。

然後跑 \(tarjan\) 的時候記錄一下 \(siz\)(搜尋樹的子樹大小)。同時維護一下所有符合條件的子樹大小之和(方便統計最後一部分對答案的貢獻)。然後直接算每個點的答案就行了。

  • 點雙連通分量(v-DCC)

不存在割點的無向連通圖為 點雙連通圖。極大點雙連通子圖為 點雙連通分量

一張圖是點雙連通圖,當且僅當圖的頂點數不超過2,或者圖的任意兩個點都在同一個簡單環(圓圈)中。

注意:一個割點可能同時屬於多個 v-DCC!(非割點只屬於一個)

  • 求法

維護一個棧。無論是否是根,在遇到 \(low[to] >= dfn[cur]\) 時彈棧一直彈到 \(to\),然後彈出的所有點再加上 \(cur\) 即為一個點雙連通分量。

比較麻煩的時縮點。有了一個個 v-DCC 後,就可以進行縮點。但是由於割點可能同時屬於多個 v-DCC,因此我們要將 割點複製一份作為中轉節點。令人欣慰的時,縮點過後原圖將成為一棵樹(或森林)。如下圖:

void tarjan(int cur) {
	stk[++stop] = cur; dfn[cur] = low[cur] = ++dcnt;
	int cnt = 0;
	for (register int i = head[cur]; i; i = e[i].nxt) {
		int to = e[i].to;
		if (!dfn[to]) {
			tarjan(to);
			low[cur] = min(low[cur], low[to]);
			if (low[to] >= dfn[cur]) {
				cnt++;
				if (cur != rt || cnt > 1)	iscut[cur] = true;
				int tmp; dcc_tot++;
				do {
					tmp = stk[stop--];
					dcc[dcc_tot].push_back(tmp);
				} while(tmp != to);
				dcc[dcc_tot].push_back(cur);
			}
		} else {
			low[cur] = min(low[cur], dfn[to]);
		}
	}
}

inline void rebuild() {
	int ntot = n;
	for (register int i = 1; i <= n; ++i) {
		if (iscut[i]) newid[i] = ++ntot;
	}
	for (register int i = 1; i <= dcc_tot; ++i) {
		for (register int j = 0; j < (int)dcc[i].size(); ++j) {
			int cur = dcc[i][j];
			if (iscut[cur]) {
				aded(nwid[cur], i); aded(i, nwid[cur]);
			} else {
				col[cur] = i;
			}
		}
	}
}
  • 典型應用:無向圖必經點

例題:P4320 道路相遇

必經點 = 刪去這個點後點對不連通 = 點雙縮點後的樹上路徑的割點樹

需要完整地建出縮點後的樹。為了防止邊混淆,在建立縮點後的樹之前先將原圖的邊清空。

注意對於路徑端點(即點對)需要特判。

(題解裡面好多說圓方樹的,不知道是不是和tarjan做法有關)

\(Code:\)my record

習題:UVA1464 Traffic Real Time Query System

題意:求無向圖中不屬於任何奇環的節點數量。其中1個點不算環。

首先我們最好把一個個環(不一定是簡單環)都提出來。這個要用點雙連通分量。因為邊雙連通分量不好搞定 \(∞\) 形狀的情況。我們要求每個“塊”內的任意兩點都屬於至少同一個環。(畢竟是點在環上的問題,而不是邊在環上的問題)

可以證明,對於每一個塊(v-DCC),如果包含有至少一個奇環,那麼塊內的所有點都在至少一個奇環上。(找到奇環後,由於要求任意兩個點都在至少同一個環上,因此奇環外的點一定會與奇環上的點以環的形式連線,出現環套環的現象。因此那一個環有兩種形態,必定是一種奇環一種偶環)。

於是黑白染色即可。由於割點在不同的DCC上,因此每次染色前需要重置“顏色”。

小於等於兩個點的DCC恰好也符合,正好不用特判。

程式碼:my record

提示:對於無向圖邊對的必經點數,為四對點的必經點數的最大值。通過找規律可以得到。嚴謹證明應該也不難,就在縮點後的數上討論各種(兩種)情況即可。

程式碼:my record

  • 習題

P3225 [HNOI2012]礦場搭建

P2860 [USACO06JAN]Redundant Paths G

有向圖相關問題

tarjan 演算法、強連通分量與縮點

有向圖的 tarjan 已經很熟悉了。

注意1:如果 \(to\) 不在棧中,就不要用它更新 \(cur\)\(low\) 了。

注意2:將 \(tmp\) 彈棧時,要 \(vis[tmp] = false\)

\(Code:\)

void tarjan(int cur) {
	dfn[cur] = low[cur] = ++dcnt;
	stk[++stop] = cur; vis[cur] = true;
	for (register int i = head[cur]; i; i = e[i].nxt) {
		int to = e[i].to;
		if (!dfn[to]) {
			tarjan(to);
			low[cur] = min(low[cur], low[to]);
		} else if (vis[to]) {
			low[cur] = min(low[cur], low[to]);
		}
	}
	if (low[cur] == dfn[cur]) {
		int tmp; ctot++;
		do {
			tmp = stk[stop--];
			col[tmp] = ctot;
			vis[tmp] = false;
		} while (tmp != cur);
	}
}

相關題目:P3387 【模板】縮點P2812 校園網路【[USACO]Network of Schools加強版】P2515 [HAOI2010]軟體安裝

有向圖的必經邊與必經點

似乎和支配樹有關?這裡只有DAG的必經邊和必經點。

\(S\) 出發沿正向邊(即原圖的邊)拓撲dp求出 \(fs[cur]\) 表示 \(S\)\(cur\) 的方案數;從 \(T\) 出發沿反向邊(即原圖的反向邊)拓撲dp求出 \(ft[cur]\) 表示 \(cur\)\(T\) 的方案數。這樣, \(fs[T]\) 即為總方案數。

如果有一條邊\((u, v)\),滿足:\(fs[u] * ft[v] == fs[T]\),即為必經點;

如果有一個點 \(cur\),滿足:\(fs[cur] * ft[cur] == fs[T]\),即為必經點。

由於方案數可能很大,需要進行Hash!

注意:

  • 一般圖的連通性的題目要對DCC/SCC為一的情況進行特判。

  • 求點雙和求SCC的寫法不太一樣,求點雙是 do { } while (tmp != to); 一直到把 \(to\) 彈出去為止(最好寫成 do-while 形式,否則容易忘記彈 \(to\),SCC同理);而求SCC則是 do { } while (tmp != cur); 要把自己彈出去

  • 點雙可以用來水過很多圓方樹的題,比如道路相遇,以及戰略遊戲。

附:

tarjan 求割邊及縮點(除錯用)

//(initial)ecnt = 1
void tarjan(int cur, int ine) {
    dfn[cur] = low[cur] = ++dtot;
    for (register int i = head[cur]; i; i = e[i].nxt) {
        int to = e[i].to;
        if (!dfn[to]) {
            tarjan(to, i);
            low[cur] = min(low[cur], low[to]);
            if (low[to] > dfn[cur]) 
                iscut[i] = iscur[i ^ 1] = true;
        } else if (i != ine ^ 1)
            low[cur] = min(low[cur], dfn[to]);
    }
}
bool vis[N];
int siz[N];
int col[N], ctot;
void Dfs(int cur) {
	vis[cur] = true;
	col[cur] = ctot;
	siz[ctot]++;
	for (register int i = head[cur]; i; i = e[i].nxt) {
		int to = e[i].to;
		if (vis[to] || iscut[i])	continue;
		Dfs(to);
	}
}
vector<int> eg[N];
inline void adeg(int u, int v) {
	eg[u].push_back(v); eg[v].push_back(u);
}

//in main()
for (register int i = 1; i <= n; ++i) {
	if (!dfn[i])	tarjan(i, 0);
}
memset(vis, 0, sizeof(vis));
for (register int i = 1; i <= n; ++i) {
	if (!vis[i])	ctot++, Dfs(i);
}
for (register int i = 2; i <= ecnt; i += 2) {
	if (iscut[i]) {
		adeg(col[e[i].to], col[e[i ^ 1].to]);
	}
}

tarjan 求割點及縮點(除錯用)

void tarjan(int cur) {
    stk[++stop] = cur; dfn[cur] = low[cur] = ++dcnt;
    int cnt = 0;
    for (register int i = head[cur]; i; i = e[i].nxt) {
        int to = e[i].to;
        if (!dfn[to]) {
            tarjan(to);
            low[cur] = min(low[cur], low[to]);
            if (low[to] >= dfn[cur]) {
                cnt++;
                if (cur != rt || cnt > 1)   iscut[cur] = true;
                int tmp; dcc_tot++;
                do {
                    tmp = stk[stop--];
                    dcc[dcc_tot].push_back(tmp);
                } while(tmp != to);
                dcc[dcc_tot].push_back(cur);
            }
        } else {
            low[cur] = min(low[cur], dfn[to]);
        }
    }
}

inline void rebuild() {
    int ntot = n;
    for (register int i = 1; i <= n; ++i) {
        if (iscut[i]) newid[i] = ++ntot;
    }
    for (register int i = 1; i <= dcc_tot; ++i) {
        for (register int j = 0; j < (int)dcc[i].size(); ++j) {
            int cur = dcc[i][j];
            if (iscut[cur]) {
                aded(nwid[cur], i); aded(i, nwid[cur]);
            } else {
                col[cur] = i;
            }
        }
    }
}

tarjan 求強連通分量(除錯用)

void tarjan(int cur) {
    dfn[cur] = low[cur] = ++dcnt;
    stk[++stop] = cur; vis[cur] = true;
    for (register int i = head[cur]; i; i = e[i].nxt) {
        int to = e[i].to;
        if (!dfn[to]) {
            tarjan(to);
            low[cur] = min(low[cur], low[to]);
        } else if (vis[to]) {
            low[cur] = min(low[cur], low[to]);
        }
    }
    if (low[cur] == dfn[cur]) {
        int tmp; ctot++;
        do {
            tmp = stk[stop--];
            col[tmp] = ctot;
            vis[tmp] = false;
        } while (tmp != cur);
    }
}