進階圖論

 

樹壓平

 

樹壓平

  • 之前其實有講過,
  • 維護每個點第一次進入 \(in[i]\) , 最後離開 \(out[i]\) 這兩個時間戳記
  • 若 \((in[c],out[c]) \in (in[x],out[x])\) 則 \(x\) 為 \(c\) 祖先

樹壓平

  • 若 \((in[c],out[c]) \in (in[x],out[x])\) 則 \(x\) 為 \(c\) 祖先
  • 若把點當作在 \(in[x]\) , 則 \((in[x],out[x])\) 恰包含 \(x\) 的子孫
  • 會更改一個點的值、求某個點子樹的總和
  • 由於樹壓平的性質,利用樹壓平轉成序列
  • 子樹總和等價詢問 \((in[x],out[x])\) 總和
  • 變成區間詢問問題  => 線段樹
#include<bits/stdc++.h>
#define Woody
#define int long long 
#define lowbit(x) (x&-x)
#define rep(n) for(int i=0;i<n;i++)
#define mp make_pair
#define eb emplace_back
#define F first
#define S second
#define SZ(a) (int)(a.size())
#define all(v) v.begin(),v.end()
#define SETIO(s) ifstream cin(s+".in");ofstream cout(s+".out");
#ifdef Woody
#define quick ios::sync_with_stdio(0);cin.tie(0);
#else
#define quick
#endif
#define INF INT64_MAX
using namespace std;
typedef pair<int,int> pii;
vector<int> in;
vector<int> out;
int t;
const int N=2e5+7;
vector<int> V[N];
vector<bool> visited;
void dfs(int x){
	in[x]=t++;
	visited[x]=true;
	for(int i:V[x]){
		if(!visited[i]) dfs(i);
	}
	out[x]=t++;
	return ;
}
struct BIT{
	vector<int> bit;
	int n;
	void init(int x){
		n=x;
		bit.assign(n+1,0);
	}
	void add(int x,int val){
		for(int i=x;i<=n;i+=lowbit(i))	bit[i]+=val;
	}
	int query(int x){
		int sum=0;
		for(int i=x;i>0;i-=lowbit(i)) sum+=bit[i];
		return sum;
	}
}tree;
signed main(){
	quick
	int n,q;
	cin>>n>>q;
	vector<int> v(n);
	rep(n) cin>>v[i];
	in.resize(n+1);
	out.resize(n+1);
	visited.assign(n+1,0);
	t=1;
	rep(n-1){
		int a,b;
		cin>>a>>b;
		V[a].eb(b);
		V[b].eb(a);
	}
	dfs(1);
 
	tree.init(t);
	for(int i=0;i<n;i++){
		tree.add(in[i+1],v[i]);
	}
	rep(q){
	//	for(int i=1;i<=t;i++) cout<<tree.query(i)<<" ";cout<<"\n";
		//system("pause");
		int r;
		cin>>r;
		if(r==1){
			int s,x;
			cin>>s>>x;
			if(v[s-1]!=x){
				tree.add(in[s],x-v[s-1]);
				v[s-1]=x;
			}
		}
		else{
			int s;
			cin>>s;
			cout<<tree.query(out[s])-tree.query(in[s]-1)<<"\n";
		}
	}
}
  • \(n\) 點的樹和\(q\)次操作
  • 每次求從根結點到 \(x\) 的路徑上的點權和
  • 一樣考慮樹壓平序列
  • 那在區間 \([in[root],in[x]]\) 之間出現恰一次的就是所求的點
  • 在 \(in[x]\) 加值、在 \(out[x]\) 扣掉即可
#include<bits/stdc++.h>
#define Woody
#define int long long 
#define lowbit(x) (x&-x)
#define rep(n) for(int i=0;i<n;i++)
#define mp make_pair
#define eb emplace_back
#define F first
#define S second
#define SZ(a) (int)(a.size())
#define all(v) v.begin(),v.end()
#define SETIO(s) ifstream cin(s+".in");ofstream cout(s+".out");
#ifdef Woody
#define quick ios::sync_with_stdio(0);cin.tie(0);
#else
#define quick
#endif
#define INF INT64_MAX
using namespace std;
typedef pair<int,int> pii;
vector<int> in;
vector<int> out;
int t;
const int N=2e5+7;
vector<int> V[N];
vector<bool> visited;
void dfs(int x){
	in[x]=t++;
	visited[x]=true;
	for(int i:V[x]){
		if(!visited[i]) dfs(i);
	}
	out[x]=t++;
	return ;
}
struct BIT{
	vector<int> bit;
	int n;
	void init(int x){
		n=x;
		bit.assign(n+1,0);
	}
	void add(int x,int val){
		for(int i=x;i<=n;i+=lowbit(i))	bit[i]+=val;
	}
	int query(int x){
		int sum=0;
		for(int i=x;i>0;i-=lowbit(i)) sum+=bit[i];
		return sum;
	}
}tree;
signed main(){
	quick
	int n,q;
	cin>>n>>q;
	vector<int> v(n);
	rep(n) cin>>v[i];
	in.resize(n+1);
	out.resize(n+1);
	visited.assign(n+1,0);
	t=1;
	rep(n-1){
		int a,b;
		cin>>a>>b;
		V[a].eb(b);
		V[b].eb(a);
	}
	dfs(1);
 
	tree.init(t);
	for(int i=0;i<n;i++){
		tree.add(in[i+1],v[i]);
		tree.add(out[i+1],-v[i]);
	}
	rep(q){
	//	for(int i=1;i<=t;i++) cout<<tree.query(i)<<" ";cout<<"\n";
		//system("pause");
		int r;
		cin>>r;
		if(r==1){
			int s,x;
			cin>>s>>x;
			if(v[s-1]!=x){
				tree.add(in[s],x-v[s-1]);
				tree.add(out[s],v[s-1]-x);
				v[s-1]=x;
			}
		}
		else{
			int s;
			cin>>s;
			cout<<tree.query(in[s])<<"\n";
		}
	}

樹上歐拉迴路

樹上歐拉迴路

  • 究極樹壓平
  • Euler tour on tree
  • 和樹壓平類似,只是並不只維護第一次進去、最後離開的時間
  • 維護每次進入、離開的時間
  • 只取第一次進入、最後一次離開即為樹壓平

樹上歐拉迴路

  • 維護每次進入、離開的時間
  • 右方 Euler tour 為
  • 1, 2, 4, 2, 5, 7, 5, 8, 5, 2, 6, 2, 1, 3, 9, 3, 1

求LCA (a,b)

  • 考慮Euler tour
  • 那 \(LCA(a,b)\) 會是在 \((in[a],in[b])\) 中深度最小的點
  • 不需倍增的LCA

動態樹直徑

  • 給你一棵有 n 個節點的樹,邊帶正權, q 筆操作,每筆操作會更改某條邊邊權並輸出當前樹直徑
  • \(n, q ≤ 10^5、邊權 ≤ 2 × 10^{13}\)

動態樹直徑

  • 用Euler tour 來看這題
  • 假設樹直徑兩點為 \(a,b\) 
  • \(d=dep[a]+dep[b]-2dep[LCA(a,b)]  \\ =  (dep[a]+dep[b]-2\min(dep[c])_{c\in (a,b)}\)
  • 線段樹處理 (可以思考一下)

樹同構

  • 給兩棵 N 個節點的有根樹,求它們是否同構(忽視節點編號)。
  • 同構意思就是可以把子樹交換...讓兩棵樹形狀相同
  • \(N ≤ 10^5\)
  • 可以把每個節點的子樹先按照大小由小到大排序,並利用 \(map\) 去給每種狀態編號...
  • 最後只要判斷是否每個編號皆相同

判斷樹同構

//Author: Woody
#include<bits/stdc++.h>
#define int long long
#define mp make_pair
#define eb emplace_back
#define rep(n) for(int i=0;i<n;i++)
#define rep2(n) for(int j=0;j<n;j++)
#define F first
#define S second
#define all(v) v.begin(),v.end()
#define SZ(x) (int)(x.size())
#define lowbit(x) (x&-x)
#define SETIO(s) ifstream cin(s+".in");ofstream cout(s+".out");
#define quick ios::sync_with_stdio(0);cin.tie(0);
using namespace std;
typedef pair<int,int> pii;
template <class t1,class t2> 
inline const pair<t1,t2> operator + (const pair<t1,t2>&p1,const pair<t1,t2>&p2){
	return pair<t1,t2>(p1.F+p2.F,p1.S+p2.S);
}
template <class t1,class t2> 
inline const pair<t1,t2> operator - (const pair<t1,t2>&p1,const pair<t1,t2>&p2){
	return pair<t1,t2>(p1.F-p2.F,p1.S-p2.S);
}
const int INF=1e18;
const int N=1e5+7;
const int Mod=998244353;
vector<int> v[2][N];
int h[2][N];
unordered_map<string,int> m;
int num=1;
void dfs(int x,int pos,int p=1){
	vector<int> v2;
	v2.eb(1);
	for(int i:v[pos][x]){
		if(i!=p){
			dfs(i,pos,x);
			v2.eb(h[pos][i]);
		}
	}
	sort(all(v2));
	string Sum;
	for(int i:v2){
		Sum+=to_string(i)+"$";
	}
	if(m.find(Sum)==m.end()) m[Sum]=num++;
	h[pos][x]=m[Sum];
}
signed main(){
	quick
	
	int t;
	cin>>t;
	while(t--){
		int n;
		cin>>n;
		fill(v[0],v[1]+N,vector<int>());
		fill(h[0],h[1]+N,0LL);
		m.clear();
		num=1;
		for(int i=0;i<n-1;i++){
			int a,b;
			cin>>a>>b;
			v[0][a].eb(b);
			v[0][b].eb(a);
		}
		dfs(1,0);
		for(int i=0;i<n-1;i++){
			int a,b;
			cin>>a>>b;
			v[1][a].eb(b);
			v[1][b].eb(a);
		}
		dfs(1,1);
		sort(h[0]+1,h[0]+n+1);
		sort(h[1]+1,h[1]+n+1);
		bool ok=true;
		for(int i=1;i<=n;i++){
			if(h[0][i]!=h[1][i]){
				ok=false;break;
			}
		}
		if(ok) cout<<"YES\n";
		else cout<<"NO\n";
	}
	return 0;
}

樹同構

  • 剛剛是有根樹,那無根樹呢?
  • Tree Isomorphism II

  • 一棵樹至多有兩個重心,兩棵樹長一樣則會有相同重心

  • 枚舉重心當樹根去判斷...

樹同構

//Author: Woody
#pragma GCC optimize("Ofast,no-stack-protector")
#include<bits/stdc++.h>
#define mp make_pair
#define eb emplace_back
#define rep(n) for(int i=0;i<n;i++)
#define rep2(n) for(int j=0;j<n;j++)
#define F first
#define S second
#define all(v) v.begin(),v.end()
#define SZ(x) (int)(x.size())
#define lowbit(x) (x&-x)
#define SETIO(s) ifstream cin(s+".in");ofstream cout(s+".out");
#define quick ios::sync_with_stdio(0);cin.tie(0);
using namespace std;
typedef pair<int,int> pii;
template <class t1,class t2> 
inline const pair<t1,t2> operator + (const pair<t1,t2>&p1,const pair<t1,t2>&p2){
	return pair<t1,t2>(p1.F+p2.F,p1.S+p2.S);
}
template <class t1,class t2> 
inline const pair<t1,t2> operator - (const pair<t1,t2>&p1,const pair<t1,t2>&p2){
	return pair<t1,t2>(p1.F-p2.F,p1.S-p2.S);
}
//const int INF=1e18;
const int N=1e5+7;
const int Mod=998244353;
int h[2][N];
vector<int> v[2][N];
vector<int> c[2];
map<vector<int>,int> m;
int sub[N];
int mx[N];
int num=1;
void get_subtree(int pos,int node,int par=-1){
	mx[node]=sub[node]=1;
	for(int i:v[pos][node]){
		if(i!=par){
			get_subtree(pos,i,node);
			sub[node]+=sub[i];
			mx[node]=max(mx[node],sub[i]);
		}
	}
}
void find_centroid(int pos,int n){
	int mx2=N;
	for(int i=1;i<=n;i++){
		mx[i]=max(mx[i],n-sub[i]);
		if(mx2>mx[i]){
			c[pos].clear();
			c[pos].eb(i);
			mx2=mx[i];
		}
		else if(mx2==mx[i]) c[pos].eb(i);
	}
}
void dfs(int pos,int node,int par=-1){
	vector<int> v2;
	v2.eb(1);
	for(int i:v[pos][node]){
		if(i!=par){
			dfs(pos,i,node);
			v2.eb(h[pos][i]);
		}
	}
	sort(all(v2));
	if(!m.count(v2)){
		m[v2]=num++;
	}
	h[pos][node]=m[v2];
}
void solve(int pos,int n){
	for(int i=0;i<n-1;i++){
		int a,b;
		cin>>a>>b;
		v[pos][a].eb(b);
		v[pos][b].eb(a);
	}
	get_subtree(pos,1);
	find_centroid(pos,n);
}
bool ok(int n){
	m.clear();
	num=1;
	dfs(0,c[0][0]);
	int p1=h[0][c[0][0]];
	for(int p2:c[1]){
		dfs(1,p2);
		if(h[1][p2]==p1) return true;
	}
	return false;
}
signed main(){
	quick
	int t;
	cin>>t;
	while(t--){
		int n;
		cin>>n;
		fill(v[0],v[1]+N,vector<int>());	
		fill(h[0],h[1]+N,0);
		fill(c,c+1,vector<int>());
		solve(0,n);
		solve(1,n);
		if(ok(n)){
			cout<<"YES\n";
		}
		else cout<<"NO\n";
	}
	return 0;
}

樹鍊剖分

 

  • 給定一顆樹和 \(q\) 次操作
  • 每次操作是下列兩種
    • \(s_i\) 值加上 \(val_i\)
    • 詢問 \((a,b)\) 路徑上的最大值
  • \(1≤n,q≤2⋅10^5\)

樹鍊(重鍊)剖分

  • 當然題目也可以是要求路徑加值、路徑詢問...
  • 想法是把樹切成一堆鍊
  • 鍊稱為重鍊,而連接兩鍊的邊稱為輕邊

樹鍊(重鍊)剖分

  • 重節點 : \(x\) 為 重節點則 \(x\) 為 \(p_x\) 中子樹最大的子節點
  • 其餘為輕點
  • 重點會和祖先在同一條鍊
  • 輕點會是新鍊的起點
  • 祖先和輕點的邊為輕邊

樹鍊(重鍊)剖分

  • 重點會和祖先在同一條鍊
  • 輕點會是新鍊的起點
  • 祖先和輕點的邊為輕邊

樹鍊(重鍊)剖分性質

  • 每個節點恰在一條重鍊
  • 每經過輕邊則子樹大小會除以二
  • 經過 \(O(\log{n})\) 條輕邊
  • 經過 \(O(\log{n})\) 條重鍊
  • 也就是可以暴力走訪重鍊

重剖求 \(LCA(a,b)\)

  • 可以每次 \(O(1)\) 把重鍊深度較深的點往上跳
  • 直到兩人在同一鍊停止,此時較淺者為 \(LCA\)
  • 因為鍊至多 \(O(\log{n})\) 條,所以只需 \(O(\log{n})\) 時間

重剖求 路徑 \(a,b\)

  • 那因為一樣至多 \(O(\log{n})\) 條鍊
  • 所以假設可以維護每條鍊的答案 (ex: 總和、最大值)
  • 就可以暴力跳時順便更新答案
  • 如何維護 ?
    • 線段樹

維護重鍊

  • 最暴力想法每條重鍊維護一棵線段樹
  • 重鍊有性質? 會是一堆相連的點
  • 所以只要讓重鍊先 dfs 就可以讓其進入(\(in\))編號連續
  • 一棵線段樹搞定

維護重鍊

實作

  • 子樹大小 \(sz[x]\)
    • 來決定一個點是不是重點
  • 所屬鍊的起點 \(top[x]\)
  • 所屬鍊的深度 \(dep[x]\)
    • 非節點的深度
  • dfs 進入順序 \(in[x]\)
  • 他的父節點 \(fa[x]\)
    • 跳鍊時會需要
  • 一棵線段樹維護答案...

實作

cses 會TLE的 code

#pragma GCC optimize("O3,unroll-loops")
#pragma GCC target("avx,popcnt,sse4,abm")
#include<bits/stdc++.h>
#define int long long
#define quick ios::sync_with_stdio(0);cin.tie(0);
#define rep(x,a,b) for(int x=a;x<=b;x++)
#define repd(x,a,b) for(int x=a;x>=b;x--)
#define lowbit(x) (x&-x)
#define sz(x) (int)(x.size())
#define F first
#define S second
#define all(x) x.begin(),x.end()
#define mp make_pair
#define eb emplace_back
using namespace std;
typedef complex<int> P;
#define X real()
#define Y imag()
typedef pair<int,int> pii;
void debug(){
    cout<<"\n";
}
template <class T,class ... U >
void debug(T a, U ... b){
    cout<<a<<" ",debug(b...);
}
const int N=2e5+7;
const int INF=1e18;
#define ll 2*x+1
#define rr 2*x+2
#define L ll,lx,mid
#define R rr,mid,rx
struct segment{
	int sz;
	vector<int> mx;
	void init(int n){
		sz=1;
		while(sz<n) sz<<=1;
		mx.assign(sz*2,0LL);
		sz=n;
		return;
	}
	void pull(int x){
		mx[x]=max(mx[ll],mx[rr]);
	}
	void build(const vector<int>&a,int x,int lx,int rx){
		if(lx==rx-1){
			if(lx<sz(a)) mx[x]=a[lx];
			return ;
		}
		int mid=(lx+rx)>>1;
		build(a,L);
		build(a,R);
		pull(x);
	}
	void update(int pos,int val,int x,int lx,int rx){
		if(lx==rx-1){
			mx[x]=val;
			return ;
		}
		int mid=(lx+rx)>>1;
		if(pos<mid) update(pos,val,L);
		else update(pos,val,R);
		pull(x);
	}
	int qry(int l,int r,int x,int lx,int rx){
		if(l<=lx&&rx<=r) return mx[x];
		if(l>=rx||lx>=r) return 0LL;
		int mid=(lx+rx)>>1;
		return max(qry(l,r,L),qry(l,r,R));
	}
	void build(const vector<int>&a){
		build(a,0,0,sz);
	}
	void update(int pos,int val){
		update(pos,val,0,0,sz);
	}
	int qry(int l,int r){
		return qry(l,r+1,0,0,sz);
	}
}st;
int top[N];
int fa[N];
int in[N];
int sz[N];
int mxc[N];
int t=0;
int V[N];
int dep[N];
vector<int> v[N];
void dfs_sz(int x,int p=-1){
	mxc[x]=-1;
	sz[x]=1;
	for(int i:v[x]){
		if(i==p) continue;
		dfs_sz(i,x);
		sz[x]+=sz[i];
		if(mxc[x]==-1||sz[mxc[x]]<sz[i]) mxc[x]=i;
	}
}
void dfs(int x,int p=1,int tp=1,int d=0){
	in[x]=t++;
	top[x]=tp;
	dep[x]=d;
	fa[x]=p;
	if(mxc[x]!=-1) dfs(mxc[x],x,tp,d);
	else return ;
	for(int i:v[x]){
		if(i==p||i==mxc[x]) continue;
		dfs(i,x,i,d+1);
	}
}
int Query(int a,int b){
	int ret=0;
	while(top[a]!=top[b]){
		if(dep[a]>dep[b]){
			ret=max(ret,st.qry(in[top[a]],in[a]));
			a=fa[top[a]];
		}
		else{
			ret=max(ret,st.qry(in[top[b]],in[b]));
			b=fa[top[b]];
		}
	}
	if(in[a]>in[b]) swap(a,b);
	return max(ret,st.qry(in[a],in[b]));
}
signed main(){
	quick
	int n,q;
	cin>>n>>q;
	rep(i,1,n) cin>>V[i];
	rep(i,1,n-1){
		int a,b;
		cin>>a>>b;
		v[a].eb(b);
		v[b].eb(a);
	}
	dfs_sz(1);
	dfs(1);
 
	vector<int> res(n);
	rep(i,1,n){
		res[in[i]]=V[i];
	}
	st.init(n);
	st.build(res);
	while(q--){
		int ti,a,b;
		cin>>ti>>a>>b;
		if(ti==1){
			st.update(in[a],b);
		}
		else{
			cout<<Query(a,b)<<" ";
		}
	}cout<<"\n";
 
	return 0;
}

值在邊上

  • 則可以把樹邊值對應給子節點
  • 詢問和操作路徑時忽略 \(LCA(a,b)\) 即可
  • 樹剖是很強大的毒瘤工具
  • 單次時間複雜度會是 \(O(\log^2{n})\) 
  • 可以做路徑修改、路徑詢問
  • 簡單來講就是原先區間可做的,重剖後一樣可做
  • 甚至可以把原先樹壓平題目耍毒
  • 要求每個點 \(i\) 有幾個子孫節點的值\(a_c\)小於他 \(a_i>a_c\)
  • \(1≤N≤10^5\)
  • \(1≤ai≤10^9\)
  • 當你按照點權由小到大排序,且相同點權時深度較小先
  • 那當掃到 \(i\) 時就直接詢問目前子樹總和、並在 \(i\) 值 +1
  • 那其實也也可以當掃到 \(i\) 時暴力在其祖先都 +1
  • 就可以利用樹剖在路徑 \((root,i)\) +1

總路徑長度

  • 給一棵 \(n\) 點的樹,每條邊有其值和 \(q\) 次詢問
  • 每次給點集 \(S_i\) , 求連接\(S_i\) 所有點的最少邊權總和
  • \(1\leq n,q,\sum{S_i} \leq 2\cdot 10^5\)

總路徑長度

  • 首先按照dfs序排好,之後把相鄰點的路徑總和加總(要包含(尾-投))
  • 因為此時會是一個樹上尤拉路徑,因此所得總合會是原先兩倍,再除以二即可

總路徑長度/點權和

  • 當題目沒有總和除以二性質(可能求點且有些點沒有值)
  • 嘗試用樹剖解
  • 再按照 \(in\) 排序後等價相鄰路徑的總和(經過多次只能算一次)
  • 利用樹剖詢問路徑和,並把路徑的價值都歸零即可
  • 圓方樹配樹剖練習題

生成樹

生成樹/森林

  • 無向圖  留下一些邊使得圖是樹/森林
  • 有向圖  留下一些邊使得圖是樹/森林,而且邊的方向是由父節點往子節點
  • 圖上的邊
    • 樹邊 (tree edge) 生成樹上的邊
    • 回邊 (back edge) 子孫連向祖先的非樹邊
    • 前向邊 (forward edge) 祖先往子孫的非樹邊
    • 交錯邊 (cross edge) 沒有祖孫關係的非樹邊

無向圖 DFS Tree

  • DFS走過的邊當樹邊
  • tree edge
  • back edge

有向圖 DFS Tree

 

  • DFS走過得當樹邊
  • 有cross edge、forward edge

時間戳記

  • 把dfs每個節點的進入、離開時間存起來
  • 通常只會需要進入時間

BCC和SCC

先備知識

  • 連通(connected無向圖上任兩點可以到達對方
  • 連通塊(connected component無向圖上的一個極大的點集,滿足其中每一對點都連通
  • 割點 / 關節點(cut vertex) 把該點刪除後會增加連通塊數量
  • 橋 (bridge) 把邊刪除後會增加連通塊數量

怎麼找橋

  • 考慮 dfs tree
  • back edge 會造成環
  • 橋切開後上下子樹會分離
  • 所以back edge不會是橋
  • 對於一條邊 (u,v) 是橋的條件是
    v無法在dfs tree上透過back edge回到 u上面(含u)

圖中,(1,5),(2,4) 就是橋

Tarjan's BCC algorithm

low[x] := 在最多經過一條back edge的情況下可以到的最小dfs序
  • 若(u,v) 是橋 , 則 in[u]<low[v]
  • 初始 in[x]=low[x]
  • dfs
  • 邊 (u,v) 是 tree edge
    • low[u]=min(low[u],low[v])
  • 邊 (u,v) 是 back edge
    • low[x]=min(low[u],in[v])

Code

const int N=2e5+7;
vector<int> v[N];
vector<pii> bridge;
int in[N];
int low[N];
int t=1;
void dfs(int x,int p=-1){
	low[x]=in[x]=t++;
	for(int i:v[x]){
		if(i!=p){
			if(in[i]){
				//back edge
				low[x]=min(low[x],in[i]);
			}
			else{
				//tree edge
				dfs(i,x);
				if(in[x]<low[i]){
					bridge.eb(mp(x,i));
				}
				low[x]=min(low[x],low[i]);
			}
		}
	}
}

邊雙連通分量 (edge-BCC)

  • 如果一個圖的所有點對都至少存在兩條不重複邊的路徑,那就稱做邊雙連通
  • 沒有橋
  • 邊雙連通分量
    • 邊雙連通的極大子圖
    • 每個點都屬於一個邊雙連通分量

找edge-BCC

  • 橋會連接兩個BCC
  • 如果 low[x]=in[x],則代表有一個BCC是在x的子樹
  • 在找橋的dfs中順便維護
  • stack維護還沒有在BCC的節點
  • 若low[x]=in[x] 就 pop 到 x為止

Code

const int N=2e5+7;
vector<int> v[N];
vector<pii> bridge;
vector<vector<int> > bcc;
stack<int> st;
int in[N];
int low[N];
int t=1;
void dfs(int x,int p=-1){
	st.push(x);
	low[x]=in[x]=t++;
	for(int i:v[x]){
		if(i!=p){
			if(in[i]){
            	//back edge
				low[x]=min(low[x],in[i]);
			}
			else{
            	//tree edge
				dfs(i,x);
				low[x]=min(low[x],low[i]);
			}
		}
	}
	if(low[x]==in[x]){//等價 low[x]>in[p] 
    	//有一個 BCC 在 x的子樹(含x)
		bcc.eb();
		while(true){
			int k=st.top();
			st.pop();
			bcc.back().eb(k);
			if(k==x) break;
		}
	}
}

edge-BCC縮點

  • 可以把同一個BCC縮成一個新節點,
    新構成的圖就會是一棵樹、由橋當邊

裸題

怎麼找割點

  • 考慮 dfs tree
  • 割點拔掉後上下子樹會分離
  • x為割點代表至少有一個子孫 i 
    滿足 in[x]<=low[i]
  • 根節點要特判,如果根結點只有一個子孫那就不是割點。

Code

const int N=2e5+7;
vector<int> v[N];
vector<int> cut;
int in[N];
int low[N];
int t=1;
bool iscut[N];
void dfs(int x,int p=-1){
	low[x]=in[x]=t++;
	int child=0;
	for(int i:v[x]){
		if(i!=p){
			if(in[i]){
				//back edge
				low[x]=min(low[x],in[i]);
			}
			else{
				//tree edge
				child++;
				dfs(i,x);
				if(low[i]>=in[x]){
					iscut[x]=true;
				}
				low[x]=min(low[x],low[i]);
			}
		}
	}
	if(p==-1&&child<2) iscut[x]=false;
	if(iscut[x]) cut.eb(x);
}

點雙連通分量 (BCC)

  • 如果圖中任兩點之間都有至少兩條不共點的路徑那就稱做點雙連通
  • 沒有割點
  • 點雙連通分量
    • 點雙連通的極大子圖
    • 每條邊都屬於一個點雙連通分量

找BCC

  • 割點會連接兩個BCC
  • 如果 in[x]<=low[i],則代表有一個BCC是在i的子樹(含(x,i)這條邊)
  • 在找割點的dfs中順便維護
  • stack維護還沒有在BCC的邊
  • 若in[x]<=in[i] 就 pop 到(x,i)為止

找BCC

  • 有時候存點會比較有用
  • stack 維護 還沒在 BCC 的節點
  • 若 in[x]<=low[i] 就 pop 到 i 為止 (但是要再把 x 放進 BCC)

Code-存邊

const int N=2e5+7;
vector<int> v[N];
vector<pii> bridge;
vector<vector<int> > bcc;
stack<int> st;
bool vis[N];
int in[N];
int low[N];
int t=1;
bool e[N];
void dfs(int x,int p=-1){
	low[x]=in[x]=t++;
	for(auto [i,e]:v[x]){
		if(!vis[e]){
			vis[e]=true;
			st.push(e);//push edge
			if(in[i]){
				//back edge
				low[x]=min(low[x],in[i]);
			}
			else{
				//tree edge
				child++;
				dfs(i,x);
				if(low[i]>=in[x]){
					bcc.eb();
					while(true){
						int x=st.top();
						st.pop();
						bcc.back().eb(x);
						if(x==e) break;
					}
				}
				low[x]=min(low[x],low[i]);
			}
		}
	}
}

Code-存點

const int N=2e5+7;
vector<int> v[N];
vector<pii> bridge;
vector<vector<int> > bcc;
stack<int> st;
bool vis[N];
int in[N];
int low[N];
int t=1;
bool e[N];
void dfs(int x,int p=-1){
	low[x]=in[x]=t++;
    st.push(x);
	for(auto [i,e]:v[x]){
		if(!vis[e]){
			vis[e]=true;
			if(in[i]){
				//back edge
				low[x]=min(low[x],in[i]);
			}
			else{
				//tree edge
				child++;
				dfs(i,x);
				if(low[i]>=in[x]){
					bcc.eb();
					while(true){
						int x2=st.top();
						st.pop();
						bcc.back().eb(x2);
						if(x2==i) break;
					}
                    bcc.back().eb(x);
				}
				low[x]=min(low[x],low[i]);
			}
		}
	}
}

BCC 縮點

  • 縮點後會變成圓方樹
  • 圓點對應割點
  • 方點對應BCC裡除了割點以外的其他點
  • 每個割點和與它相鄰的節點都是一個BCC

裸題

習題

強連通分量(SCC)

SCC

  • 強連通圖 任兩點都可以互相到達的有向圖
  • 強連通分量
    • 強連通的極大子圖
    • 每個點都屬於一個SCC

Tarjan's SCC algorithm

  • 和 edge-BCC差不多,只是要考慮該點是否在stack內
  • 如果不在的話就代表那條邊是 cross edge 不用理會

Code

const int N=2e5+7;
bool instk[N];
int in[N];
int low[N];
int t=1;
vector<vector<int> > scc;
vector<int> v[N];
stack<int> st;
void dfs(int x){
	in[x]=low[x]=t++;
	st.push(x);
	instk[x]=true;
	for(int i:v[x]){
		if(!in[i]){
			dfs(i);
			low[x]=min(low[x],low[i]);
		}
		else if(instk[i]){
			low[x]=min(low[x],in[i]);
		}
	}
	if(low[x]==in[x]){
		scc.eb();
		while(true){
			int k=st.top();
			st.pop();
			scc.back().eb(k);
			instk[k]=false;
			if(k==x){
				break;
			}
		}
	}
}

Kosaraju algorithm

  • SCC把環縮成一個點後會變成DAG
  • 為了讓一次只跑到一個環
    按照拓撲排序的逆序走訪
  • 利用兩次 dfs 
    • 第一次記錄走訪的順序
    • 第二次按照第一次的dfs的逆序走訪
      同一次走到的就是同一個 SCC

Code

const int N=2e5+7;
const int INF=1e18;
vector<int> order;
bool vis[N];
int scc[N];
vector<int> v[N];
vector<int> v2[N];
int num=0;
void Revdfs(int x){
	vis[x]=true;
	for(int i:v2[x]){
		if(!vis[i]){
			Revdfs(i);
		}
	}
	order.eb(x);
}
void dfs(int x){
	scc[x]=num;
	for(int i:v[x]){
		if(!scc[i]){
			dfs(i);
		}
	}
}

void Kosaraju(int n){
	rep(i,1,n){
		if(!vis[i])Revdfs(i);
	}
	reverse(all(order));
	for(int i:order){
		if(!scc[i]) ++num,dfs(i);
	}

}
pii e[N];
int deg[N];
signed main(){
    quick
	int n,m;
	cin>>n>>m;
	rep(i,1,m){
		cin>>e[i].F>>e[i].S;
		v[e[i].F].eb(e[i].S);
		v2[e[i].S].eb(e[i].F);
	}
	Kosaraju(n)
    return 0;
}

例題

Codeforces 1137C Museums Tour

 

有 \(N\) 個城市 , \(M\) 條路,一星期有 \(D\) 天。

每一天乘客一定要從一個城市移動到另一個城市,如果不行移動就結束。然後每個城市每星期有特定的天數有展覽,最後要問你最多能看幾個相異的展覽。

範圍: \(N,M \leq 10000,D\leq 50\)

 

例題

可以對每個點\(i\) 開 \( (i,d)\) 代表在星期\(d\) 到達該點。
之後若\((i \rightarrow j)\)則 \(\forall t:1 \sim d ~  \{ (i,t) \rightarrow (j, t \mod d +1) \}\)

但要注意這裡雖然是建邊但你不能直接在 vector 上建,要在迴圈中直接枚舉,不然會MLE。

習題

2-SAT

2-SAT

a ∨ b \leftrightarrow (\neg a \rightarrow b) \leftrightarrow (\neg b \rightarrow a)
可等價於上面的數學式子,
所以可以對 (-a,b) ,(-b,a) 建有向邊,
最後只要 (a,-a) 在同一個 SCC 內則無解。

構造解

  • 對同一個 SCC 內 的值一定相同
  • 若 v 可以走到 -v , 那一定只能選 -v
  • 按照拓撲排序的順序,選擇編號較後的

裸題

二分圖匹配

二分圖匹配

  • 二分圖 (bipartite graph)
    • 可以把點分成兩堆且同一堆內的節點間都沒有邊
  • 匹配 (matching)
    • 找一些點兩兩配對,每個點只能在一個匹配中且同一匹配的點必須相鄰
  • 最大匹配 (maximum matching)
    • 有最多配對的匹配
  • 匹配是一個邊的獨立集

名詞介紹

  • 匹配點 有被配對的點
  • 匹配邊 兩端是一個配對的邊 
  • 交錯路徑 
    • 交替經過未匹配邊、匹配邊的路徑
  • 增廣路 
    • 一種滿足頭尾都是未匹配點的交錯路徑

Berge's lemma

找二分圖最大匹配

  • 根據 Berge's lemma 就一直找增廣路即可
  • 把圖分成兩半 \(X,Y\) 
  • 枚舉 \(X\)的點 如果有找到增廣路則反轉邊,匹配數+1
  • 時間複雜度 \(O(|V||E|)\)

Code

const int N=1e3+7;
vector<int> v[N];//X的鄰點
int match[N]; // Y所匹配的節點
bool vis[N]; //在這次尋找中是否被走過
int dfs(int x){
	for(int y:v[x]){
		if(vis[y]) continue;
		vis[y]=true;
		if(match[y]==-1||dfs(match[y])){
			match[y]=x;
			return true;
		}
	}
	return false;
}
int bipartiteMatching(int n){
	fill(match,match+n+1,-1);
	int ans=0;
	rep(i,1,n){
		fill(vis,vis+n+1,0);
		if(dfs(i)) ans++;
	}
	return ans;
}

一些集合

  • 點覆蓋 用點去覆蓋所有邊的點集合
  • 點獨立集 互不相鄰的點集
  • 邊覆蓋 用邊去覆蓋所有點的邊集合
  • 邊獨立集 互不共點的邊集 ( 匹配 )

符號

  • 二分圖左右兩側 \(X,Y\)

  • 最小點覆蓋 \(C_v\)

  • 最小邊覆蓋 \(C_e\)

  • 最大點獨立集 \(I\)

  • 最大邊獨立集 = 最大匹配 = \(M\)

性質

  • \(|C_v|=M\)
  • \(|I|=|V|-|C_v|\)
  • \(|M|=|V|-|C_e|\)

Kőnig’s theorem

 因為覆蓋 \(M\) 條匹配至少需要 \(|M|\)個點

所以 \(|C_v| \geq |M|\)

所以只要構造出一組點獨立集 \(|C_v|=|M|\)即可

構造方法如下:

從 左邊沒有被匹配的點去走符合增廣路 "交替出現" 要求的路徑(未匹配邊-匹配邊-未匹配邊...) 並標記路過的所有點。

之後左邊沒被標記到的點 + 右邊被標記的點 即為最小點覆蓋

Kőnig’s theorem

證明- 1 ( 該得到的點集可以覆蓋所有的邊 ):

證明不可能有一條邊 左端有標記,右端沒標記

  • 假設這條邊屬於匹配
    • 則右端的標記必定是從左邊而來,故兩端都有或都沒有
  • 假設這條邊不屬於匹配
    • 則右端點可以從左端點走去,故兩者都會有標記

 

Kőnig’s theorem

假定得到點集為 \(|P|\)

證明-2 \(|P|=|M|\)

  • 左邊未被標記點代表他是一條匹配邊左端(且該條匹配邊沒被標記過)
  • 右邊被標記點也代表他是一條匹配邊右端
  • 所以 \(|P|=|M|\)

Kőnig’s theorem

由證明-1,證明-2 再加上前面所述,

可保證這構造出來的是最小點覆蓋

故得證 \(|C_v|=|M|\)

如果還有不懂可以參考這個網頁的證明

最大點獨立集

  • 任意點獨立集的補集就是點覆蓋
    • 可以想一下
  • 最小點覆蓋的補集就是最大點獨立集
  • \(I=V \setminus C_v\)

最大點獨立集

  • 最小點覆蓋的補集就是最大點獨立集
  • \(I=V \setminus C_v\)

最小邊覆蓋

首先圖不會有孤點,不然一定無解

 

 1. 把 \(M\)中的邊都加入 \(C_e\) 則覆蓋\(2|M|\)個點,

最多再選\(V-2|M|\)條邊集可覆蓋所有點

故\(|C_e|\leq |V|-|M|\)

 

最小邊覆蓋

2. 因為 \(C_e\) 上若有環則至少可以拔掉一條邊,

故 \(C_e\) 為森林,連通塊數量為 \(|V|-|C_e|\)

則從每個連通塊選一條邊會形成邊獨立集

因為邊獨立集不會超過最大邊獨立集

所以連通塊數量必須小於最大邊獨立集(最大匹配)

故 \(|V|-|C_e|\leq |M|\)

最小邊覆蓋

由 1. ,2. 得證 \(|C_e|=|V|-|M|\)

例題

CSES Coin Grid

在\(N \times N\) 上的棋盤上有\(K\)個錢,

每次你可以選擇清空一列、一行上的錢,

求最少操作幾次才能把棋盤清空

並把操作的方法列出來

例題

  • 把題目放在二分圖上思考,
  • 每個皮皮\(x,y\)要被打掉代表 \(x\)被選或是\(y\)被選
  • 最小點覆蓋

例題

  • 最小點覆蓋構造方法
    • 如前面證明時所使用的方法,就是枚舉 \(Y\) 中未被匹配的點,然後去找部分增廣路徑然後把經過的點標記
    • 左邊被標記的點 + 右邊未被標記的點即為答案
  • 實作方法:
    • 把未匹配的邊改成右指向左
    • 匹配邊為左指向右
    • 之後就是 DFS
  • 參考Code

構造Code

const int N=1e3+7;
vector<int> v[N];//X的鄰點
int match[N]; // Y所匹配的節點
bool vis[N]; //在這次尋找中是否被走過
int dfs(int x){
	for(int y:v[x]){
		if(vis[y]) continue;
		vis[y]=true;
		if(match[y]==-1||dfs(match[y])){
			match[y]=x;
			return true;
		}
	}
	return false;
}
int bipartiteMatching(int n){
	fill(match,match+n+1,-1);
	int ans=0;
	rep(i,1,n){
		fill(vis,vis+n+1,0);
		if(dfs(i)) ans++;
	}
	return ans;
}
vector<int> v2[2*N];
int tag[2*N];
vector<pii> e;
void paint(int x){
	tag[x]=true;
	for(int i:v2[x]){
		if(!tag[i]){
			paint(i);
		}
	}
}
void getce(int n){
	int ans=bipartiteMatching(n);
	cout<<ans<<"\n";
	for(pii p2:e){
		if(match[p2.S]!=p2.F){
			v2[p2.S+n].eb(p2.F);
		}
		else v2[p2.F].eb(p2.S+n);
	}
	rep(i,1,n){
		if(match[i]==-1){
			paint(i+n);
		}
	}
	rep(i,1,n){
		if(tag[i]) cout<<1<<" "<<i<<"\n";
	}
	rep(i,n+1,2*n){
		if(!tag[i]) cout<<2<<" "<<i-n<<"\n";
	}
}

例題

TIOJ 1069 . E.魔法部的任務

有一個棋盤,然後接下來會有 \(m\) 個事件發生,第 \(i\)個事件會在時間 \(t_i\) 發生在 (\(x_i\),\(y_i\)),你可以派一些人去處理這些事件。一個人被派出去的時候,他一開始可以在任何位置,接下來他移動到別的地方花的時間等於曼哈頓距離。求你至少要派幾個人。
\( m \leq 1000 \)

例題

對事件建點,

若從 \(i\) 事件結束後可以接著做 \(j\) 事件

則建邊 \(i \rightarrow j\) 

等價於要用最少的路徑去使每個節點都被走訪

可以先假設答案是 \(m\)

那之後會發現如果有一個匹配 \(i,j)\) 則 答案會 -1。

故最佳解即為 \(|V|-|M|\)

參考Code

練習

adv-graph

By yuhung94

adv-graph

  • 199