1. 程式人生 > 實用技巧 >tarjan複習筆記 雙連通分量,強連通分量

tarjan複習筆記 雙連通分量,強連通分量

宣告:圖自行參考割點和橋QVQ

雙連通分量

  • 如果一個無向連通圖\(G=(V,E)\)中不存在割點(相對於這個圖),則稱它為點雙連通圖

  • 如果一個無向連通圖\(G=(V,E)\)中不存在割邊(相對於這個圖),則稱它為邊雙連通圖

  • 無向圖的極大點雙連通子圖稱為點雙連通分量,簡稱\(v-DCC\)

  • 無向圖的極大邊雙連通子圖稱為邊雙連通分量,簡稱\(e-DCC\)

  • 如果稱一個雙連通子圖\(G'=(V',E')\)極大,當且僅當不存在\(G\)的另外一個子圖\(G''=(V'',E'')\neq G'\),使得\(G'\)\(G''\)的子圖且\(G''\)是雙連通子圖

\(e-DCC\)
(邊雙)

求法
  • 刪除原圖中所有的橋,剩下的連通塊均為\(e-DCC\).

  • 先用\(Tarjan\)標記所有的橋,在DFS每個連通塊,給各個點分配所在的\(e-DCC\)的編號即可

縮點法
  • 在有些具有特殊性質的問題中,可以把一個\(e-DCC\)看做一個點進行處理

  • 可以考慮一下求得所有的\(e-DCC\),然後建一張新圖,僅保留所有的\(e-DCC\)和橋

  • 這種將一個雙連通分量收縮為一個節點的方法稱為縮點

  • 程式碼實現中我們可以把每一個邊雙的編號看做是節點編號,如果兩個邊雙之間有橋,那麼在新圖中在這兩個點之間連邊即可

  • 新圖是一棵樹或者森林

程式碼簡述(求無向圖中的橋,邊雙連通分量,並進行縮點)
void tarjan(int x,int in_edge)
{
	low[x]=dfn[x]=++poi;
	for(int i=head[x];i;i=e[i].last)
	{
		int y=e[i].to;
		if(!dfn[y])
		{
			tarjan(y,i);
			low[x]=min(low[x],low[y]);
			if(low[y]>dfn[x])
				bridge[i]=bridge[i^1]=1;
		}
		else if(i!=(in_edge^1))//如果這個邊不是上次的反向邊
			 low[x]=min(low[x],dfn[y]); 
	}
}

首先根據\(Tarjan\)求橋的原理\(dfn[n]<low[y]\)求出橋,但是要注意的是這是雙向邊,所以正邊和反邊都要打標記,在這裡我們可以用位運算"^"實現反邊的操作,奇-1,偶+1

void dfs(int x)
{
	c[x]=dcc;
	for(int i=head[x];i;i=e[i].last)
	{
		int y=e[i].to;
		if(c[y]||bridge[i]) continue;//如果這個點已經有了儲存的值或者這條邊是橋就不進行
		//是橋的話要是弄進去那說明這個子圖中就有橋了。不符合 
		dfs(y); 
	}
}

然後用深搜給每個都進行標號,如果這個點是已經有編號了或者該邊是橋,那麼就繼續找

int main()
{
	int n,m;
	cin>>n>>m;
	cnt=1;//保證運算簡便,邊的編號從2開始 
	for(int i=1;i<=m;i++)
	{
		int x,y;
		cin>>x>>y;
		add(x,y);
		add(y,x);
	}
	for(int i=1;i<=n;i++)
		if(!dfn[i]) tarjan(i,0);//“^”運算,奇數-1,偶數+1
	for(int i=2;i<cnt;i+=2)
		if(bridge[i])
			cout<<e[i^1].to<<" "<<e[i].to<<endl; 
	for(int i=1;i<=n;i++)
	{
		if(!c[i])
		{
			++dcc;
			dfs(i); 
		} 
	}
	cout<<"There are "<<dcc<<" e-DCCs"<<endl;
	for(int i=1;i<=n;i++)
	{
		cout<<i<<" belongs to DCC "<<c[i]<<endl; 
	}
	c_cnt=1;//邊還是從2開始,便於計數
	for(int i=2;i<=cnt;i++)
	{
		int x=e[i^1].to;
		int y=e[i].to;
		if(c[x]==c[y])continue;
		c_add(c[x],c[y]);//縮點建圖 
	} 
	cout<<"縮點以後的森林,點數為 "<<dcc<<" 邊數為 "<<c_cnt/2<<endl;
	for(int i=2;i<c_cnt;i++)
	{
		cout<<ce[i^1].to<<" "<<ce[i].to<<endl; 
	}
	return 0;
}
  • 主函式裡面,首先我們把邊的計數值設為\(1\),那麼邊的編號就是從\(2\)開始,便於用"^"進行運算
  • 然後先進行\(Tarjan\)把所有的橋找出來,進行深搜
  • 當然因為是雙向的,所以反向邊一塊處理了即可,都標記為橋
  • 然後就開始進行標號啦,深搜進行標號
  • 然後我們就可以計算出有多少個邊雙連通分量以及他們的從屬關係
  • 然後就開始建立新的圖,編號還是從2開始,便於計算
  • 至於為什麼只建立有向邊,因為這個編號是+1+1處理的,它的反向邊一定會建立
  • 最後就看結果就好啦QVQ

\(v-DCC\)(點雙)

上圖!

求法
  • 如果一個點被孤立了,那麼它就自己構成一個點雙,否則點雙的大小至少為\(2\)

  • 一個割點可以被多個點雙包含,其餘點只能在一個點雙裡面

  • 看上面的圖,圖中的割點為\(1,6\)

-圖中的點雙為\([1,2,3,4,5],[1,6],[6,7],[6,8,9]\)

  • 得出構造方法
    1、先在原圖中削除所有的割點
    2、列舉剩下的所有連通塊,然後向每一個連通塊中新增原圖中與該連通塊相連的割點
    3、然後一個點雙就誕生了

  • 於是偉大的哲人"他姐"發明了一個基於棧的做法

  • 我們可以在\(Tarjan\)的過程中維護一個棧,並且按照如下的元素維護
    1、當一個節點第一次被訪問到時,入棧
    2、當搜到一個節點\(x\)且發先一個兒子\(y\)滿足割點法則\(dfn_x<=low_y\)時,無論\(x\)是否為根,都要從棧頂不斷彈出棧,然後直到\(y\)出棧,並將剛才的元素與\(x\)共同構成一個點雙
    3、用vector維護即可

縮點法
  • 保留割點,並且將所有的點雙都縮成一個點

  • 每個點雙向自身包含的割點中進行連邊

  • 如果原圖中一共有\(x\)個割點,\(y\)個點雙,新圖中一共有\(x+y\)個點

  • 新圖中是一個樹或者是森林

程式碼實現
void tarjan(int x)
{
	dfn[x]=low[x]=++poi;
	suk[++top]=x;//將第一遍搜過的點入棧 
	if(x==root&&head[x]==0)//判斷孤立點 
	{
		dcc[++sum].push_back(x);
		return;
	}
	int flag=0;
	for(int i=head[x];i;i=e[i].last)
	{
		int y=e[i].to;
		if(!dfn[y])
		{
			tarjan(y);
			low[x]=min(low[x],low[y]);
			if(low[y]>=dfn[x])//如果這是一個割點 
			{
				flag++;
				if(x!=root||flag>1) cut[x]=1;//割點 
				int z;
				sum++;
				do{
					z=suk[top--];
					dcc[sum].push_back(z); 
				}while(z!=y);
				dcc[sum].push_back(x);//形成一個新的v-DCC 
			}
		}
		else low[x]=min(low[x],dfn[y]);
	}
}

首先還是進行\(Tarjan\)處理,求出每個點雙並且將割點標記。

int main()
{
	cin>>n>>m;
	cnt=1;
	for(int i=1;i<=m;i++)
	{
		int x,y;
		cin>>x>>y;
		if(x==y) continue;
		add(x,y);
		add(y,x); 
	} 
	for(int i=1;i<=n;i++)
	{
		if(!dfn[i])
		{
			root=i;
			tarjan(i);
		}
	}
	for(int i=1;i<=n;i++)
	{
		if(cut[i])
		cout<<i<<" "; 
	} 
	cout<<"are cut-vertexes"<<endl;
	for(int i=1;i<=sum;i++)
	{
		cout<<"e-DCC #"<<i<<": ";
		for(int j=0;j<dcc[i].size();j++)
		{
			cout<<dcc[i][j]<<" ";
		}
		cout<<endl; 
	}
	int js=sum;
	for(int i=1;i<=n;i++)
	{
		if(cut[i])
		new_id[i]=++js;//建立新的 
	}
	c_cnt=1;//從2開始方便計算 
	for(int i=1;i<=sum;i++)// 建新圖,從每個v-DCC到它包含的所有割點連邊
	{
		for(int j=0;j<dcc[i].size();j++)
		{
			int x=dcc[i][j];
			if(cut[x])
			{
				c_add(i,new_id[x]);
				c_add(new_id[x],i);
			}
			else c[x]=i; 
		}
	}
	cout<<"縮點後的森林,點數為"<<js<<" 邊數為 "<<c_cnt/2<<endl;
	printf("編號 1~%d 的為原圖的v-DCC,編號 >%d 的為原圖割點\n", sum, sum);
	for(int i=2;i<c_cnt;i+=2)
		printf("%d %d ",ce[i^1].to,ce[i].to);
	return 0;
}

然後就是龐大的主函數了(提醒一下,在我的理解中root應該只出現在求割點的時候,其他時候基本木有)

  • 首先,初始值設為1,編號從\(2\)開始建立雙邊(如果是單向的你也要建立,因為割點只存在於無向圖中)
  • 然後進行\(Tarjan\)處理
  • 當我們找完的時候,所有的點雙和割點都已經被我們求出來啦
  • 然後我們就可以愉快的找出每一個點雙裡的元素
  • 此時,我們的所有點雙已經被標完編號了,然後就開始給所有的割點建立新編號\(new-id\)
  • 建立完以後還是編號從\(2\)開始分別找每個點雙裡面的割點,然後向他連雙向邊,然後把其他不是割點的點統計一下所在的點雙編號
  • 最後輸出就好了!完美結束

強連通分量

  • 對於一個有向圖,若關於任意的兩節點\(x,y\),既存在從\(x\)\(y\)的路徑,同時也存在\(y\)\(x\)的路徑,則稱該有向圖是強連通圖

  • 對於有向圖的極大強連通子圖稱為強連通分量,記為\(SCC\)

  • \(Tarjan\)演算法能夠線上性時間內求解有向圖所有的強連通分量

特殊定義

  • 給定一個有向圖\(G=(V,E)\),存在\(r\in V\)\(r\)能到達\(V\)中的任何點,則稱\(G\)是一個流圖,記為\((G,r)\),\(r\)稱作\(G\)的源點

  • 與無向圖類似,在流圖上從\(r\)出發開始DFS,每個節點只訪問一次

  • 所有發生遞迴的邊構成一棵以\(r\)為根的樹,稱之為流圖\((G,r)\)搜尋樹

  • 按照每個節點第一次訪問的時間順序依次標號,該整數標號稱為時間戳,記為\(dfs_x\)

流圖中的邊

  • 流圖中的有向邊\((x,y)\)一定是一下四種之一:
    1、樹枝邊,搜尋樹上的
    2、前向邊,不存在於搜尋樹上,且在搜尋樹中\(x\)\(y\)的祖先
    3、後向邊,不存在與搜尋樹上,且在搜尋樹中\(y\)\(x\)的祖先
    4、橫叉邊,不是上述三種情況的邊,那麼一定有\(dfn_y<=\)dfn_x,否則會在DFS的時候經過\(y\)從而構成樹枝邊

  • 看圖理解一下

SCC的求法

定義梳理
  • 根據定義,這一定是一個環,那麼所有的環一定是強連通圖

  • \(Tarjan\)演算法的基本思路就是對於每一個點,都儘量找到與它一起能構成環的所有節點

不同的邊的貢獻
  • 對於一條邊\((x,y)\)我們討論一下他的型別
    1、前向邊,對找環沒有用,因為在搜尋樹中本來就存在\(x->y\)的路徑
    2、後向邊,對找環很有用,因為在搜尋樹中可以和\(x->y\)的路徑構成一個環
    3、橫叉邊,對找環可能有用,如果從\(y\)出發能找到一條路徑回到\(x\)的祖先節點,則可以構成一個環,它就是有用的
遍歷
  • 為了找到通過後向邊和橫叉邊構成的環,\(Tarjan\)演算法在DFS是維護一個棧

  • 當第一次訪問到這個點時,入棧

  • 訪問到\(x\)時,棧中儲存了一下的兩類點:
    1、搜尋樹上\(x\)的祖先節點
    2、已經訪問過,存在一條路徑能夠到達\(x\)的祖先的點

  • 這些節點都存在一條到達\(x\)的路徑,如果\(x\)也能到達他們,那麼就構成了一個環

追溯值
  • 可以理解為\(x\)的搜尋子樹上\(x\)能到達的時間戳最小的能到達\(x\)的點
構建方法
  • 當第一次訪問到\(x\)是,首先令\(low_x=dfn_x\)

-在考慮與\(x\)相連的每一條邊,DFS回溯的時候更新\(low_x\)

  • 如果\(y\)沒有被訪問,那麼就遞迴的訪問,則\(low_x=min(low_x,low_y)\)

  • 但如果\(y\)在棧中,那麼\(low_x=min(low_x,dfn_y)\)

  • (重點)當\(x\)回溯以前,首先先判斷是否有\(low_x=dfn_x\)

  • 如果有,那麼久不斷彈棧直到\(x\)出棧

  • 彈棧的所有節點構成了一個SCC

構建理解
  • 當我們回溯完畢時,已經考慮了\(x\)能到達的所有節點

  • \(y\)被訪問過並且不再棧中:\(x\)能達到\(y\),但\(y\)無法到達\(x\)

  • 由回溯完畢可知已經考慮了所有\(y\)能到達的節點,所以如果\(y\)能到達\(x\),那麼\((x,y)\)不會是橫叉邊,所以\(y\)\(low_x\)沒有貢獻

縮點法
  • 將所有的強連通分量看做一個節點

  • 將每個\(SCC\)的編號看做節點的編號,如果兩個強連通分量之間有有向邊,那麼在新圖中這兩個點之間連上同一個方向的邊即可

  • 縮掉所有的環之後,就會得到一張DAG,我們就可以在上面做處理

程式碼實現
void tarjan(int x)
{
	low[x]=dfn[x]=++poi;
	suk[++top]=x;
	ins[x]=1;
	for(int i=head[x];i;i=e[i].last)
	{
		int y=e[i].to;
		if(!dfn[y])
		{
			tarjan(y);
			low[x]=min(low[x],low[y]);
		} 
		else if(ins[y])
		low[x]=min(low[x],dfn[y]);
	}
	if(low[x]==dfn[x])
	{
		sum++;
		int y;
		do{
			y=suk[top--];
			ins[y]=0;
			c[y]=sum;
			scc[sum].push_back(y);
		}while(x!=y);
	}
}

首先進行\(Tarjan\)求出所有的量,然後在回溯之前判斷一下即可

int main()
{
	cin>>n>>m;
	for(int i=1;i<=m;i++)
	{
		int x,y;
		cin>>x>>y;
		add(x,y);
	}
	for(int i=1;i<=n;i++)
	{
		if(!dfn[i])
			tarjan(i); 
	}
	for(int x=1;x<=n;x++)
	{
		for(int i=head[x];i;i=e[i].last)
		{
			int y=e[i].to;
			if(c[x]==c[y]) continue;
			c_add(c[x],c[y]); 
		}
	}
}

在主函式中,遍歷完一遍以後,開始列舉每個點的所有編號,根據有向圖的變得方向進行建邊

例題

P3387 P3388 P2341 P3469 P2194 P1262
P1262 P2002 P2746 P5058