進階圖論
樹壓平
樹壓平
- 之前其實有講過,
- 維護每個點第一次進入 \(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;
}樹同構
- 剛剛是有根樹,那無根樹呢?
-
一棵樹至多有兩個重心,兩棵樹長一樣則會有相同重心
- 枚舉重心當樹根去判斷...
樹同構
//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;
}
例題
有 \(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) ,(-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
最大點獨立集
- 任意點獨立集的補集就是點覆蓋
- 可以想一下
- 最小點覆蓋的補集就是最大點獨立集
- \(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|\)
例題
例題
- 把題目放在二分圖上思考,
- 每個皮皮\(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";
}
}例題
有一個棋盤,然後接下來會有 \(m\) 個事件發生,第 \(i\)個事件會在時間 \(t_i\) 發生在 (\(x_i\),\(y_i\)),你可以派一些人去處理這些事件。一個人被派出去的時候,他一開始可以在任何位置,接下來他移動到別的地方花的時間等於曼哈頓距離。求你至少要派幾個人。
\( m \leq 1000 \)
例題
對事件建點,
若從 \(i\) 事件結束後可以接著做 \(j\) 事件
則建邊 \(i \rightarrow j\)
等價於要用最少的路徑去使每個節點都被走訪
可以先假設答案是 \(m\)
那之後會發現如果有一個匹配 \(i,j)\) 則 答案會 -1。
故最佳解即為 \(|V|-|M|\)
練習
- TIOJ 2037 . 警力配置
-
GCJ 2021 R2 pD Retiling Test Set 1
- Test Set1 (權重為1)
adv-graph
By yuhung94
adv-graph
- 199