图论知识点摘要
Tips:本文适合快速复习,非教学文章。
最短路
-
Dijkstra:建小根堆维护 \(dis\),每次抽出堆顶 \(u\),如果访问过就跳过,否则标记为访问过,遍历 \(u\) 的出边 \((u,v,w)\),如果 \(dis[v]>dis[u]+w\) 就更新 \(v\) 的 \(dis\),更新到了 \(v\) 就
push
进堆,复杂度 \(O(m\ log\ n)\),不适用于负权。Code
int dis[MAXN]; bool vis[MAXN]; priority_queue<pii,vector<pii>,greater<pii>>pq; void Dijkstra(int s){ memset(dis,0x3f,sizeof(dis)); memset(vis,0,sizeof(vis)); dis[s]=0;pq.push(make_pair(dis[s],s)); int u,v; while(!pq.empty()){ u=pq.top().second;pq.pop(); if(vis[u]) continue; vis[u]=1; for(auto it:g[u]){ v=it.first; if(dis[v]>dis[u]+it.second){ dis[v]=dis[u]+it.second; pq.push(make_pair(dis[v],v)); } } } }
-
SPFA:取出队头 \(u\),扫描出边 \((u,v,w)\) 判断能否松弛 \(v\),如果能,则更新 \(v\) 的
dis
与cnt
(到达该点的最短路所经过边数),且 \(v\) 不在队列里就让 \(v\) 进队,复杂度期望 \(O(km)\),\(k\) 为一个小常数,但最坏可以被卡为 \(O(nm)\),负权可用,可判负环,如果经过了 \(\geq n\) 条边则说明出现了负环。SLF优化
\(x\) 进队时,如果更新后的 \(dist[x]\) 比队头小就
push_front
,否则push_back
,复杂度 \(O(\)好一点但还是能被卡\()\)。Code
int dis[MAXN],cnt[MAXN]; bool vis[MAXN]; bool SPFA(int s){//return true if negative cycle exists memset(dis,0x3f,sizeof(dis)); memset(vis,0,sizeof(vis)); queue<int>q; dis[s]=0; q.push(s);vis[s]=1; int u,v; while(!q.empty()){ u=q.front();q.pop();vis[u]=0; for(auto it:g[u]){ v=it.first; if(dis[v]>dis[u]+it.second){ dis[v]=dis[u]+it.second; cnt[v]=cnt[u]+1; if(cnt[v]>=n) return true; if(!vis[v]) q.push(v),vis[v]=1; } } } return false; }
-
Floyd:依次枚举 \(k,x,y\),状态转移
f[x][y]=min(f[x][y],f[x][k]+f[k][y])
,复杂度\(O(n^3)\),一般情况下不能有负环。第一维可以省略的解释
Floyd 的原型为
f[k][x][y]=min(f[k-1][x][y], f[k-1][x][k]+f[k-1][k][y])
(f[k][x][y]
表示中途只经过 \(1\sim k\) 的节点,\(x\) 到 \(y\) 的最短距离)。其状态数组第一维是可以省略的,原因为 ① \(k\) 的状态只与 \(k-1\) 有关;②f[x][k],f[k][y]
对应的还是f[k-1][x][k],[k-1]f[k][y]
,而不会是f[k][x][k],f[k][k][y]
,因为如果 \(x\) 到 \(k\) 或 \(k\) 到 \(y\) 的路径中途经过了 \(k\),说明存在一个环,而最短路径在没有负环的情况下是不可能有环的。Floyd 求传递闭包
什么是图的传递闭包?
人话:判断两点之间是否能连通。
用邻接矩阵存边,由于只用知道能不能到而不用知道要走多远,用
bool
存边即可。可以用 bitset 进行优化,复杂度降为 \(O(\frac{n^3}{w})\)。for(int k=1;k<=n;k++){ for(int i=1;i<=n;i++){ if(f[i][k]) f[i]=f[i]|f[k]; } }
补充:
- 分层图最短路:若求最短路同时还能进行一些“特殊操作”,且操作次数有限,就可以用分层图跑最短路解决。
最小生成树(MST)
-
Kruskal:建一个并查集,将边从小到大排序,从小到大扫描每一条边 \((u,v,w)\),如果 \(u,v\) 不在同一个集合就将该边加入 MST,复杂度\(O(m\ log\ m)\)。
-
Prim:设数组 \(dis\) 为 \(\infty\)(初始点为 \(0\)),这里 \(dis\) 不是到起点距离,而是到 MST 里的点的最短距离。建以 \(dis\) 为关键字的小根堆,将初始点加入堆。开始循环直至堆空,每次抽出堆顶 \(u\),如果已经在 MST 中就
continue
,否则加入将该点以及该点 \(dis\) 所代表的边加入 MST。遍历 \(u\) 的出边 \((u,v,w)\),如果 \(w<dis[v]\) 就更新 \(dis[v]\) 并将 \(v\) 加入堆。复杂度 \(O((n+m)\ log\ n)\),在稠密图上表现良好,稀疏图上欠佳。更简洁的说法
每次找与 MST 中节点边权最小的非 MST 的节点加入,并加入那条边权最小的边。
补充:
严格次小生成树:
一个显然的结论是次小生成树和最小生成树之间只有一条边不同(由于 MST 不一定唯一,严格来说是次小生成树与其对应的最小生成树之间只有一条边不同),然后我们枚举所有没有加入 MST 的边,强制加入这条边,此时会出现一个环,我们用倍增找到这个环上(除了新加边)的最大边权,将这条最大边删去便可以得到候选的次小生成树。但是某些情况下这条最大边有可能和新加边边权相同,此时我们要删环上(除了新加边)的次大边而非最大边。
最小瓶颈路:
\(x\) 到 \(y\) 的最小瓶颈路满足这条路径上的最大的边权在所有 \(x\) 到 \(y\) 的简单路径中是最小的。\(x\) 到 \(y\) 的最小瓶颈路上的最大边权等于最小生成树上 \(x\) 到 \(y\) 路径上的最大边权。
Kruskal 重构树:
对于 Kruskal 每次找出的两个连通块的祖先,我们新建一个点作为两个祖先的父亲,并将当前边的边权转化为新点的点权,得到的二叉树便为 Kruskal 重构树。
可以发现 Kruskal 重构树有如下性质:① 原图中两点之间路径瓶颈(两点之间所有简单路径中路径上最大边权的最小值)= MST 上两点之间路径上最大边权 = Kruskal 重构树上两点 LCA 的点权。② 到点 \(x\) 路径上瓶颈 \(\leq val\) 的所有点 \(y\) 均在 Kruskal 重构树上的一棵子树内,且恰好为子树的所有叶子节点。该子树的根节点为重构树上 \(x\) 到根的路径上点权 \(\leq val\) 的最浅的节点。
树的直径
树的直径指最长的树上任意两点间简单路径,树的直径不唯一,但长度相等,且当树边权均为正的时候所有直径的中点重合。
-
两次 DFS:任取一点出发找到离它最远的节点 \(p\),再从 \(p\) 出发找到离 \(p\) 最远的节点 \(q\),\(p\) 到 \(q\) 的路径即为直径,复杂度 \(O(n)\)。
-
树形 DP:令 \(d[x]\) 为 \(x\) 到 \(x\) 的子树中点的最远距离,遍历整棵树,在回溯时,令父节点为 \(u\),子节点为 \(v\),边权为 \(w\),先
ans=max(ans,d[u]+d[v]+w)
,再d[u]=max(d[u],d[v]+w)
,复杂度 \(O(n)\)。DP 转移式解释
更新到第 \(i\) 个儿子节点此时 \(d[u]\) 就是 \(\max\limits_{1\leq j<i}\{d[j]+dis[u][j]\}\),先用 \(i\) 到 \(j(1\leq j<i)\) 的最大距离更新答案,再更新
d[u]
。树形 DP 魔改求直径路径
记录每个点到其子树最远和次远路径的下一个点,并记录下直径的中点
node
。在输出直径路径时分别从node
的最远和次远路径下一个点,再一直跳最远的下一个点,输出顺序需要重新整理。
两次 DFS 法更易求具体路径,但边权不能为负;树形 DP 法可以有负权边,但不易求路径。
树链剖分
树剖目的:将一棵树完全拆分为一些链,使得链上 DFS 序连续。
重链剖分
性质:树上任意路径可以被拆分为 \(O(\log n)\) 条链。
称重子节点为某个节点所有儿子节点中子树最大的节点。
两次 DFS,第一次求出每个节点的父节点 fa
,深度 dep
,子树大小 size
,重子节点 hson
;第二次进行 DFS 编号 dfn
,记录 dfn
对应的节点编号 rank
,并记录链头 top
,注意为了保证同一条链上 DFS 序连续,遍历儿子时需要先搜重子节点再搜其他儿子。
Code
int fa[MAXN],dep[MAXN],sz[MAXN],hson[MAXN],top[MAXN],dfn[MAXN],dfncnt,rk[MAXN];
void dfs1(int x){
hson[x]=-1;
sz[x]=1;
for(int it:g[x]){
if(it!=fa[x]){
dep[it]=dep[x]+1;
fa[it]=x;
dfs1(it);
sz[x]+=sz[it];
if(hson[x]==-1 || sz[it]>sz[hson[x]]) hson[x]=it;
}
}
}
void dfs2(int x,int topid){
top[x]=topid;
dfn[x]=++dfncnt;
rk[dfncnt]=x;
if(hson[x]==-1) return;
dfs2(hson[x],topid);
for(int it:g[x]){
if(it!=hson[x] && it!=fa[x]) dfs2(it,it);
}
}
长链剖分
待补充
最近公共祖先(LCA)
-
向上标记法:\(x\) 向上走到根节点并标记经过的节点,\(y\) 再向上走,遇到第一个被标记的就是它们的 LCA,复杂度 \(O(n)\),仅适合单次询问。
-
树上倍增法:
fa[x][i]
记录 \(x\) 的第 \(2^i\) 代祖先,dep[x]
表示 \(x\) 的深度,预处理复杂度 \(O(n\log n)\),查询复杂度 \(O(\log n)\),适合多次询问。- 预处理
fa
dep
两个数组。 - 对于每次询问,首先通过
swap
保证 \(x\) 比 \(y\) 深(或者反过来)。 - 令 \(\Delta d\) 表示 \(x,y\) 的深度差,将 \(\Delta d\) 进行二进制表示,如果第 \(k\) 位为 \(1\) 那么就让 \(x\) 往上走 \(2^k\) 步,最后 \(x,y\) 深度相等。
- 如果此时 \(x=y\),答案就是 \(x\)。
- 将 \(k\) 从高到低从 \(\log_2 n\) 枚举到 \(0\),如果
fa[x][k]!=fa[y][k]
,就将 \(x,y\) 同时向上移 \(2^k\) 步,最后答案就是fa[x][0]
。
Code
class LCA{ private: int fa[MAXN][MAXB],dep[MAXN]; void dfs(int u,int fa){ dep[u]=dep[fa]+1; fa[u][0]=fa; for(int i=1;i<MAXB;i++){ fa[u][i]=fa[fa[u][i-1]][i-1]; } for(auto v:g[u]){ if(v==fa) continue; dfs(v,u); } } public: void init(int rt){ dep[0]=0; dfs(rt,0); } int getlca(int x,int y){ if(dep[x]>dep[y]) swap(x,y); int delta=dep[y]-dep[x]; for(int i=0;delta;i++,delta>>=1){ if(delta&1) y=fa[y][i]; } if(x==y) return y; for(int i=MAXB-1;i>=0 && x!=y;i--){ if(fa[x][i]!=fa[y][i]){ x=fa[x][i]; y=fa[y][i]; } } return fa[x][0]; } };
- 预处理
-
Tarjan 法:离线所有询问并且记录下每个点对应的询问,开始深搜,标记搜到的点
vis[x]=1
,对于 \(x\) 遍历它的所有出边 \(v\),如果 \(v\) 没访问过就先深搜它,然后并查集合并 \(u\) 和 \(v\)(注意这里并查集必须是儿子合并到父亲下面)。遍历完所有出边后遍历自己对应的询问,如果询问的另一个节点vis[y]=2
,则 \(\operatorname{LCA}(x,y)\) 就为当前并查集中 \(y\) 的根,在回溯时标记vis[x]=2
。常见的 Tarjan 求 LCA 复杂度为 \(O(n+q\cdot\alpha(n+q))\),并不是严格线性,且常数较大,很多时候甚至不如倍增和树剖。是离线算法。 -
树链剖分法(推荐):预处理复杂度 \(O(n)\),查询复杂度 \(O(\log n)\)。常数很小。具体做法为重链剖分后,不断跳两点所在链链头较深的那个,直到跳到同一个链上,此时 LCA 为深度较小的那个点。
Code
inline int LCA(int x,int y){ while(top[x]!=top[y]){ if(dep[top[x]]<dep[top[y]]) swap(x,y); x=fa[top[x]]; } return dep[x]<dep[y]?x:y; }
-
RMQ 法:
-
欧拉序 RMQ:
欧拉序:指每被访问到一次则记录一次,包括首次访问与后续回溯访问。一棵 \(n\) 个节点的树的欧拉序长为 \(2n-1\)。
记录下每个点在欧拉序中第一次出现的位置记为 \(id(x)\),则 \(u,v\) 的 LCA 一定为欧拉序区间 \([id(u),id(v)]\) 的最小值。
-
DFS 时间戳 RMQ:
要求 \(\operatorname{LCA}(u,v)\),首先特判 \(u=v\) 的情况,然后通过
swap
确保 \(dfn_u<dfn_v\),建立一个数组 \(A\),对于每个节点 \(x\) 令 \(A_{dfn_x}\) 为其父亲的 \(dfn\),则 \(u\) 与 \(v\) 的 LCA 的 \(dfn\) 为 \(A\) 上 \(dfn_u+1\) 到 \(dfn_v\) 的最小值。解释
称 \(\operatorname{LCA}(u,v)=d\),深搜从 \(u\) 编号到 \(v\) 时中途一定经过了 \(d\) 的某个儿子节点,其子树包含 \(v\),记该节点为 \(v'\),而由于 \(d\) 子树内节点的 \(dfn\) 一定大于它自己的 \(dfn\) 并且 \(dfn_u\) 到 \(dfn_v\) 之间一定都是 \(d\) 子树内的节点,因此记录下每个节点的父亲,则 \(dfn_u\) 到 \(dfn_v\) 中时间戳最小的父亲即为 LCA(\(v'\) 的父亲)。而同时为了兼容 \(u\) 为 \(v\) 祖先的情况,我们从 \(dfn_u+1\) 开始搜。
-
树的重心
树的重心,指子节点的子树大小最大值最小的节点,可以理解为树的重心的子节点都尽可能的均匀分布。
- 一颗树至多有两个重心,仅当树有偶数个节点树才可能会有两个重心,且这两个重心一定是相邻的。
- 以树重心为根时,任意子树大小不超过树大小的一半,反过来说也是成立的。
- 以树重心为根时,树上所有点的深度之和最小。
- 将两棵树相连时,新的重心一定在原先两棵树的重心的路径上。
根据定义求解即可。
Code
int sz[MAXN],centroid[3];
void getcentroid(int x,int fa){
sz[x]=1;
int maxson=0;
for(auto it:g[x]){
if(it==fa) continue;
getcentroid(it,x);
maxson=max(maxson,sz[it]);
sz[x]+=sz[it];
}
maxson=max(maxson,n-sz[x]);
if(maxson<=n/2)
centroid[++centroid[0]]=x;
}
树上差分
称我们需要使树上 \(x\) 到 \(y\) 的路径上的所有 点/边 的 点权/边权 增加的量为 \(val\),称差分数组为 \(diff[i]\)。
-
边差分:
diff[x]+=val, diff[y]+=val, diff[LCA(x,y)]-=2*val
,询问时每个点的子树的差分数组和就是该点到它父亲的边的边权。 -
点差分:
diff[x]+=val, diff[y]+=val, diff[LCA(x,y)]-=val, diff[fa[LCA(x,y)]]-=val
,询问时每个点的子树的差分数组和就是该点点权。
基环树
基环树没有什么特殊板子,主要思想有 ①将环看作整体的“根节点”,非环部分分为若干颗子树处理。②断开环上的某条边使其变为一颗标准树。
拓扑排序
拓扑排序可以对 DAG 中所有点编号,使得对于任何有向边 \((u,v)\),\(u\) 编号一定在 \(v\) 的前面。
首先建立集合 \(S\) 存储初始所有入度为 \(0\) 的点,每次从 \(S\) 中(任意)抽出一个点 \(u\),将点 \(u\) 编号(从小到大编号),并将该点与其相连的边 \((u,v)\) 删除,若边删掉之后有 \(v\) 入度变为 \(0\),则将 \(v\) 加入集合 \(S\)。
差分约束
主要思想:转化为最短路中经典的三角形不等式 \(dis_j\leq dis_i+w\)。
基本式:\(x_a-x_b\leq c \Rightarrow x_a\leq x_b+c\Rightarrow add(b,a,c)\)
题意 | 转化 | 连边 |
---|---|---|
\(x_a-x_b\geq c\) | \(x_b\leq x_a+(-c)\) | add(a,b,-c) |
\(x_a-x_b\leq c\) | \(x_a\leq x_b+c\) | add(b,a,c) |
\(x_a=x_b\) | \(x_a\leq x_b+0,x_b\leq x_a+0\) | add(b,a,0), add(a,b,0) |
建立超级源点 \(0\),将 \(0\) 与所有点建立一条边权为 \(0\) 的边,从 \(0\) 跑单源最短路。
有负环说明无解,否则有解:\(x_i=dis_i\)。
显然若 \(\{x_1,x_2,...,x_n\}\) 为一组合法解,那么 \(\{x_1+d,x_2+d,...,x_n+d\}\) 也为一组合法解。
连通性问题(Tarjan)
基本框架都是一样的,设 dfn
与 low
两个数组分别表示时间戳与依靠非搜索树边能到达的时间戳最小的节点,对于 Tarjan 函数 tarjan(u)
,首先打时间戳 dfn[u]=low[u]=++dfncnt
,然后访问 \(u\) 的出边 \(v\),如果 \(v\) 没被访问过就 tarjan(v)
且然后用 low[v]
更新 low[u]
,否则视情况用 dfn[v]
更新 low[u]
。
Tarjan 求 SCC 主要针对有向图,因为无向图的 SCC 可以直接深搜求。而割点和割边(同理点双和边双)通常是在无向图意义下讲的。
复杂度均为\(O(n)\)。
若无特殊说明,下式中 \(u\) 为 \(v\) 的父节点,下列描述均为 tarjan(u)
函数中的做法。
强连通分量(SCC)
任意两点之间可以相互到达的子图为强连通分量。
若没访问过 \(v\),tarjan(v)
搜索它,然后用 low[v]
更新 low[u]
;如果访问过且 \(v\) 在栈中那么用 dfn[v]
更新 low[u]
。遍历完所有出边后如若 low[u]
仍等于 low[v]
就弹栈直到 \(u\) 被弹出,弹出的节点集合构成一个 SCC。
Code
int dfn[MAXN],low[MAXN],dfncnt=0,sccnum[MAXN],scccnt=0;
stack<int>st;
bool insta[MAXN];
void tarjan(int x){
dfn[x]=low[x]=++dfncnt;
st.push(x);insta[x]=1;
for(int it:g[x]){
if(!dfn[it]){
tarjan(it);
low[x]=min(low[x],low[it]);
}
else if(insta[it]){
low[x]=min(low[x],dfn[it]);
}
}
if(dfn[x]==low[x]){
scccnt++;
sccsum[scccnt]=0;
int cur;
while(st.top()!=x){
cur=st.top();st.pop();
insta[cur]=0;
sccnum[cur]=scccnt;
}
cur=st.top();st.pop();
insta[cur]=0;
sccnum[cur]=scccnt;
}
}
for(int i=1;i<=n;i++)
if(!dfn[i]) tarjan(i);
-
SCC 缩点:
直接将原边的两侧都换为缩点后的编号,视情况考虑是否去重边。
Code
for(int i=1;i<=m;i++){//SCC缩点 auto [u,v]=edge[i]; u=sccnum[u]; v=sccnum[v]; if(u!=v) new_g[u].push_back(v); }
割点
无向图中,如果一个点被删去后图的某一部分与另外某一部分不再连通(连通分量数增加),该点被称为割点。
若没访问过 \(v\),tarjan(v)
搜索它,然后用 low[v]
更新 low[u]
;如果访问过则用 dfn[v]
更新 low[u]
。如果 low[v]>=dfn[u]
且 \(u\) 不为搜索树的根,\(u\) 即为割点,如果 \(u\) 为搜索树的根则需至少有 \(2\) 个儿子满足上述条件才为割点。严格来讲,不能用父亲的 dfn
来更新自己的 low
,但在求割点时不影响答案。
Code
int dfn[MAXN],low[MAXN],dfncnt=0;
vector<vector<int>>cutvex;
void tarjan(int x,bool root){
dfn[x]=low[x]=++dfncnt;
int sontot=0;
for(int it:g[x]){
if(!dfn[it]){
tarjan(it,0);
low[x]=min(low[x],low[it]);
sontot+=(int)(low[v]>=dfn[x]);
}
else{
low[x]=min(low[x],dfn[it]);
}
}
if(sontot>(int)root) cutvex.push_back(x);
}
for(int i=1;i<=n;i++){
if(!dfn[i]) tarjan(i,1);
}
点双连通分量(v-BCC)
极大的不存在割点的连通分量被称为点双连通分量。
先特判孤点。每访问到一个点把他加入一个栈,其他流程与求割点无异,若在访问到出边 \((u,v)\) 时发现 low[v]>=dfn[u]
,弹栈直到 \(v\) 被弹出(注意只弹到 \(v\),与求 SCC 不同),弹出的点与 \(u\) 一起构成一个 v-BCC(割点可以同时存在于多个点双连通分量中)。
Code
int dfn[MAXN],dfncnt=0,low[MAXN],rt,bcccnt=0;
vector<int>g[MAXN],bcc[MAXN];
stack<int>st;
void tarjan(int x){
dfn[x]=low[x]=++dfncnt;
st.push(x);
if(rt==x && g[x].empty()){//特判孤点
bcc[++bcccnt].push_back(x);
return;
}
for(int it:g[x]){
if(!dfn[it]){
tarjan(it);
low[x]=min(low[x],low[it]);
if(low[it]>=dfn[x]){
bcccnt++;
int cur;
do{
cur=st.top();st.pop();
bcc[bcccnt].push_back(cur);
}while(cur!=it);
bcc[bcccnt].push_back(x);
}
}
else low[x]=min(low[x],dfn[it]);
}
}
for(int i=1;i<=n;i++){
if(!dfn[i]) rt=i,tarjan(i);
}
-
点双缩点:
令 v-BCC 有 \(cnt\) 个,从 \(cnt+1\) 开始给割点重新编号,将每一个 v-BCC 所对应的割点与当前的 v-BCC 所对应的节点连边。
割边/桥
无向图中,如果一条边被删去后图的某一部分与另外某一部分不再连通(连通分量数增加),该边被称为割边/桥。
若没访问过 \(v\),tarjan(v)
搜索它,然后用 low[v]
更新 low[u]
,如果 low[v]>dfn[u]
,\((u,v)\) 即为桥;否则用 dfn[v]
更新 low[u]
,注意不能用父亲的 dfn
来更新自己的 low
,但有重边时例外。
Code
int n,m,dfn[MAXN],dfncnt=0,low[MAXN],head[MAXN]={0};
struct EDGE{
int v,nxt;
bool isbridge=0;
}e[MAXM<<1];//此处需要网络流式建图法,边i与i^1互为反边
void tarjan(int x,int from){
dfn[x]=low[x]=++dfncnt;
for(int i=head[x];i;i=e[i].nxt){
EDGE *y=&e[i];
if(!dfn[y->v]){
tarjan(y->v,i);
low[x]=min(low[x],low[y->v]);
if(low[y->v]>dfn[x]) e[i].isbridge=e[i^1].isbridge=1;
}
else if(i!=(from^1)){
low[x]=min(low[x],dfn[y->v]);
}
}
}
for(int i=1;i<=n;i++){
if(!dfn[i]) tarjan(i,0);
}
边双连通分量(e-BCC)
极大的不存在割边的连通分量被称为边双连通分量。
删除所有割边,求原图的连通分量即可。
Code
int dfn[MAXN],dfncnt=0,low[MAXN],head[MAXN]={0};
struct EDGE{
int v,nxt;
bool isbridge=0;
}e[MAXM<<1];//网络流式建图法,边i与i^1互为反边
void tarjan(int x,int from){
dfn[x]=low[x]=++dfncnt;
for(int i=head[x];i;i=e[i].nxt){
EDGE *y=&e[i];
if(!dfn[y->v]){
tarjan(y->v,i);
low[x]=min(low[x],low[y->v]);
if(low[y->v]>dfn[x]) e[i].isbridge=e[i^1].isbridge=1;
}
else if(i!=(from^1)){
low[x]=min(low[x],dfn[y->v]);
}
}
}
vector<vector<int>>bcc;
vector<int>cur;
bool vis[MAXN]={0};
void dfs(int x){
vis[x]=1;
cur.push_back(x);
for(int i=head[x];i;i=e[i].nxt){
if(vis[e[i].v] || e[i].isbridge) continue;
dfs(e[i].v);
}
}
for(int i=1;i<=n;i++){
if(!dfn[i]) tarjan(i,0);
}
for(int i=1;i<=n;i++){
if(!vis[i]){
cur.clear();
dfs(i);
bcc.push_back(cur);
}
}
-
边双缩点:
求完边双后将每个 e-BCC 重新编号,再把每条割边的两端点代表的 e-BCC 的点连回来即可。
2-SAT
2-SAT 问题通常可以抽象为,有 \(n\) 个量,取值只能为真或假,有一些形如“\(A\) 选了 \(val_1\) 则 \(B\) 就必须选 \(val_2\)” 的条件,求可行解或判断无解。
将每个量拆成两个点,分别代表这个量的两种取值,对于上述条件就将代表 \(A\) 取 \(val_1\) 的点向代表 \(B\) 取 \(val_2\) 的点连一条有向边。然后求 SCC,如果存在某个量,代表其两种取值的点都在同一个 SCC 中则说明无解;否则说明有解,且变量的取值为缩点后拓扑序较后的那个点所代表的取值,由于 Tarjan 是递归算法,求的 SCC 编号为反拓扑序,即取所在 SCC 编号较小的点所代表的取值。
为什么取拓扑序较后的点的取值?
假设 \(val\) 的拓扑序在 \(\neg val\) 前,如果变量 \(x\) 取值为 \(val\) 则可推出 \(x=\neg val\),矛盾。
常见转化:
- \(a \operatorname{xor} b=0\):\(a_0\rightarrow b_0\),\(a_1\rightarrow b_1\),\(b_0\rightarrow a_0\),\(b_0\rightarrow a_0\)。
- \(a \operatorname{xor} b=1\):\(a_0\rightarrow b_1\),\(a_1\rightarrow b_0\),\(b_0\rightarrow a_1\),\(b_1\rightarrow a_0\)。
- \(a \operatorname{or} b=1\):\(a_0\rightarrow b_1\),\(b_0\rightarrow a_1\)。
- \(a=1\)(等于 \(0\) 同理):\(a_0\rightarrow a_1\),这样做保证了 \(a_1\) 的拓扑序在 \(a_0\) 之后。
圆方树
待补充
欧拉图
“一笔画”:边不可以重复,点可以重复。
- 欧拉回路:通过图中每条边恰好一次的回路
- 欧拉通路:通过图中每条边恰好一次的通路
- 欧拉图:具有欧拉回路的图
- 半欧拉图:具有欧拉通路但不具有欧拉回路的图
- 无向图是欧拉图当且仅当:
- 非零度顶点是连通的
- 顶点的度数都是偶数
- 无向图是半欧拉图当且仅当:
- 非零度顶点是连通的
- 恰有 \(2\) 个奇度顶点(有 \(2\) 个时分别为起点终点)
- 有向图是欧拉图当且仅当:
- 非零度顶点是强连通的
- 每个顶点的入度和出度相等
- 有向图是半欧拉图当且仅当:
- 非零度顶点是弱连通的
- 至多一个顶点出度比入度多 \(1\)(起点),至多一个顶点入度比出度多 \(1\)(终点)
DAG 的必经点、边
求 DAG 上 \(S\) 到 \(T\) 的必经点、必经边。
根据拓扑序求出从 \(S\) 开始到点 \(x\) 的路径条数,得数组\(fs[x]\);再在反图上拓扑求出从 \(T\) 开始到点 \(x\) 的路径条数,得数组 \(ft[x]\)。
- 必经点:对于一个点 \(x\),若 \(fs[x]\times ft[x]=fs[T]\),该点为必经点
- 必经边:对于一条有向边 \((x,y)\),若 \(fs[x]\times ft[y]=fs[T]\),改边为必经边
补充:Lengauer-Tarjan 算法可在\(O(n\log n)\)的时间复杂度内求解无向图上的必经点,见下文“支配树”。
二分图
-
二分图:没有奇环的图叫二分图,可将二分图视为点集被划分为左右两部分,所有边都横跨左右部分的图。
-
图匹配:图中不具有公共端点的边的集合。
-
增广路:从非匹配点连到另一个非匹配点的长度为奇数的路径,满足路径中非匹配边与匹配边交替出现(第奇数条为非匹配边,第偶数条为匹配边)。当一条增广路被找到后,将路径上边的匹配状态反转,即可使匹配数 \(+1\)。注意:二分图的增广路与网络流的增广路并不是同一个概念。
-
最大匹配:不存在增广路的匹配。
-
完美匹配:左部点或右部点全部被匹配,完美匹配一定是最大匹配。
判断二分图
DFS/BFS 进行染色,共有黑白两种颜色,若一个节点被染色后存在相邻节点染成了相同的颜色,则说明不是二分图,否则是二分图。
Hall 定理
待补充
求最大匹配
-
转化为最大流模型:建立超级源点,与所有左部点连容量为 \(1\) 的边;再建立超级汇点,与所有右部点连容量为 \(1\) 的边,跑最大流。Dinic 算法可做到整体复杂度为 \(O(\sqrt{n}m)\),具体证明见此处。
-
匈牙利算法:记每个点与之匹配的点为
match
。遍历所有点出发找增广路。如果某个点相邻的点没被占用则说明找到了增广路,否则可以尝试去搜该相邻点已经匹配的节点。时间复杂度为 \(O(nm)\),相比 Dinic 除了码量稍小外没有其他优势。Code
bool vis[MAXN]; int match[MAXN]; bool dfs(int x){ for(auto it:g[x]){ if(vis[it]) continue; vis[it]=1; if(!match[it] || dfs(match[it])){ match[it]=x; return true; } } return false; } for(int i=1;i<=n_left;i++){ if(!match[i]){ memset(vis,0,sizeof(vis)); if(dfs(i)) ans++; } }
算法正确性
由于增广路长度为奇数,则增广路的首尾端点一定是一个左部点一个右部点,不妨假设出发点为左部点,则找增广路变为:从一个未匹配的左部点出发,经过一条简单路径,其中从左到右是非匹配边,从右到左是匹配边,最后找到一个未匹配的右部点。但由于我们并不知道谁是左部点谁是右部点因此遍历所有点。
为什么只需要遍历所有点一次就可以?见证明。
求最大带权匹配
-
转化为费用流模型:建立超级源点,与所有左部点连容量为 \(1\) 费用为 \(0\) 的边;再建立超级汇点,与所有右部点连容量为 \(1\) 费用为 \(0\) 的边,跑最大费用最大流。
-
KM 算法:
- 顶标:给左侧节点 \(i\) 一个标记值 \(A_i\),右侧节点 \(j\) 一个标记值 \(B_j\),满足 \(A_i+B_j\geq w(i,j)\)。
- 相等子图:二分图中所有节点,和满足 \(A_i+B_j=w(i,j)\) 的边构成的子图。
- 定理:若相等子图存在完备匹配,则该匹配为该二分图的带权最大匹配。
- 局限性:由于 KM 算法只适用于最大权完美匹配,若二分图左右两侧的点数量不同,则需先补点,并设不存在的边,边权为 \(0\)。
- 算法思想:随意赋初始顶标,然后采取适当策略不断扩大相等子图的规模,直至相等子图存在完备匹配。
左侧点顶标初值赋为连接它的边的最大边权,右侧点顶标初值赋为 \(0\)。对于每个左侧点 \(i\),不断循环直到找到一条该点出发的增广路,每次循环尝试深搜找相等子图中的增广路,记录下 \(y\) 对应的最小的非零的\(A_x+B_y-w(x,y)\),记为
delta[y]
。若深搜找到了一条 \(i\) 出发的增广路,那么就可以直接跳出循环寻找下一个左侧点了;否则记录下刚才深搜中不在相等子图中的右侧点delta
的最小值mindelta
,并且将在相等子图中的左侧节点顶标全部减去mindelta
,在相等子图中的右侧节点顶标全部加上mindelta
——可以发现,这样做不会影响相等子图上原有的边,因为左边减右边加和不变,但相等子图会增加至少一条新边。复杂度 \(O(n^2m)\),可优化至 \(O(n^3)\)。
Code
bool visx[MAXN],visy[MAXN]; int lx[MAXN],ly[MAXN],match[MAXN],slack[MAXN]; bool dfs(int x) { int delta; visx[x]=true; for(int y=1;y<=ny;y++){ if(visy[y]) continue; delta=lx[x]+ly[y]-g[x][y]; if(delta==0){//(x,y)在相等子图中 visy[y]=true; if(!match[y] || dfs(match[y])){ match[y]=x; return true; } } else slack[y]=min(slack[y],delta); } return false; } void KM() { for(int i=1;i<=nx;i++){ memset(slack,0x3f,sizeof(slack)); while(true){ memset(visx,0,sizeof(visx)); memset(visy,0,sizeof(visy)); if(dfs(i)) break;//找到增广路了 int mindelta=INT_MAX; for(int y=1;y<=ny;y++) if(!visy[y]) mindelta=min(mindelta,slack[y]); for(int x=1;x<=nx;x++) if(visx[x]) lx[x]-=mindelta; for(int y=1;y<=ny;y++){ if(visy[y]) ly[y]+=mindelta; else slack[y]-=mindelta;//把所有的slack值都减去mindelta,因为lx[i]减小了mindelta } } } }
二分图覆盖与独立集
-
二分图最小点覆盖:选最少的点,满足每条边至少有一个端点被选。
-
二分图最大独立集:选最多的点,满足两两之间没有边相连。
-
定理 1(König 定理):二分图最小点覆盖数等于二分图最大匹配数。
-
定理 2:二分图最大独立集的大小等于二分图点数减去最大匹配数。
补充:
DAG 上的最小点覆盖:拆点,每个点 \(i\) 拆为编号 \(i\) 和 \(i+n\) 两个点分别放在二分图左右两侧,对于每条边 \((x,y)\),在二分图上从 \(x\) 到 \(y+n\) 建立一条边,最后在新二分图上跑最大匹配并利用 König 定理求解。
网络流
网络流的增广路与二分图中的含义不同,网络流中的增广路为从源点到汇点的一条路径。
最大流
-
Ford-Fulkerson 算法:不断 dfs 找增广路,更新残留容量与反向路径,直到找不到增广路。
-
Edmonds-Karp 算法:将 FF 算法的 dfs 找增广路换成 bfs 以减少迭代次数。
-
Dinic 算法(推荐):EK 算法的进一步优化,bfs 和 dfs 的结合。复杂度 \(O(n^2m)\),实际经常可以过 \(1e5\) 的数据。
- bfs 分层,限制 dfs 搜索范围使其不会绕路。
- 多路增广,一次 dfs 可以找到多条增广路。
- 当前弧优化,避免访问已经用完容量的边。
每次先在残量网络中 bfs 划分层次
d
(深度数组),重置每个节点的cur
(当前弧优化数组),若 bfs 无法到达汇点说明算法结束。然后从源点开始 dfs,dfs(u)
内每访问到一条边 \(egde_i(u,v)\) 便更新cur[u]=i
,深搜下一层(即d[v]==d[u]+1
)的节点,若搜出来返回 \(edge_i(u,v)\) 实际流量为 \(0\) 则说明 \(v\) 已经增广完毕,将其d
设为 \(-1\);否则正向边减实际流量、反向边加实际流量,最后返回 \(u\) 的所有实际流出流量。源点所有实际流出流量即为汇点所有实际流入流量。Code
int ecnt=1,head[MAXN],cur[MAXN],d[MAXN]; struct EDGE{ int v,nxt; LL w; }e[MAXM<<1];//ecnt从1开始,边i的反边为i^1 inline void add(const int& u,const int& v,const int& w){ e[++ecnt].v=v; e[ecnt].w=w; e[ecnt].nxt=head[u]; head[u]=ecnt; } inline void add_dual(const int& u,const int& v,const int& w){//加边时正向边为容量反向边为0 add(u,v,w);add(v,u,0); } inline bool bfs(){ for(int i=1;i<=n;i++) d[i]=-1; queue<int>q; q.push(s); d[s]=0; cur[s]=head[s]; while(!q.empty()){ int u=q.front();q.pop(); for(int i=head[u];i;i=e[i].nxt){ int v=e[i].v; if(e[i].w>0 && d[v]==-1){ q.push(v); cur[v]=head[v]; d[v]=d[u]+1; if(v==t) return true; } } } return false; } LL dfs(int x,LL flow){ if(x==t) return flow; LL tmp,res=0; for(int i=cur[x];i && flow>0;i=e[i].nxt){ cur[x]=i; int v=e[i].v; if(e[i].w>0 && d[v]==d[x]+1){ tmp=dfs(v,min(flow,(LL)e[i].w)); if(tmp==0) d[v]=-1; e[i].w-=tmp; e[i^1].w+=tmp; res+=tmp; flow-=tmp; } } return res; } while(bfs()) ans+=dfs(s,LLINF);
-
ISAP 算法:Dinic 算法的优化,只用开头 bfs 一次,每次增广后自动修改分层。复杂度与 Dinic 同阶,理论上比 Dinic 更优,但未经充分测试。
- 高度思想:类比水往低处流,ISAP 给每个节点赋予了一个高度,\(s\) 最高 \(t\) 最低,增广路一定是一条高度严格递减 \(1\) 的路径,当某个点已经流了所有比他低的点但仍没流完,就只能提升高度来流更多的节点,即所谓的“自动分层”。
- 间隙优化:记录每个高度的点的数量,若有高度点数等于 \(0\),说明出现空隙,此时无论如何也找不了新的增广路,直接输出答案结束程序。
首先 bfs 从 \(t\) 出发递增标记初始高度
hei
,然后不断循环直至有空隙出现或者源点的高度都超过了 \(n\),每次循环从源点开始深搜,\(u\) 只深搜高度为 \(hei[u]+1\) 的节点,正向边减实际流量、反向边加实际流量,若遍历出边时发现流入的流量全部流出,就返回流出的流量;不然说明流量没流完,需要尝试增加自己的高度,于是将自己的高度设为自己出边中最低的点的高度 \(+1\) 并更新gap
数组,若出现空隙说明算法结束,最后返回 \(u\) 的所有实际流出流量。Code
int n,m,s,t,cur[MAXN],hei[MAXN],gap[MAXN];//cur当前弧优化 hei记录高度 gap记录某个高度的节点个数 int ecnt=1,head[MAXN];//ecnt从1开始,边i的反边为i^1 struct EDGE{ int u,v,nxt; LL w; }e[MAXM<<1]; void bfs(){ queue<int>q; q.push(t); hei[t]=1; while(!q.empty()){ int u=q.front();q.pop(); for(int i=head[u];i;i=e[i].nxt){ int v=e[i].v; if(e[i^1].w/*不是反向边*/ && hei[v]==0/*没被访问过*/){ hei[v]=hei[u]+1; q.push(v); } } } } LL ISAP(int u,LL flow){ if(u==t) return flow; LL k=0,res=0; bool flag=false; for(int i=cur[u];i;i=e[i].nxt){ cur[u]=i; int v=e[i].v; if(e[i].w>0 && hei[v]==hei[u]-1){//注意此处是搜高度更小的节点 k=ISAP(v,min(flow,e[i].w)); flag=true; e[i].w-=k; e[i^1].w+=k; res+=k; flow-=k; if(flow==0) return res; } } if(!flag){ gap[hei[u]]--; if(!gap[hei[u]]) gap[s]=n+2;//出现间隙,算法结束 int minhei=n+2; for(int i=head[u];i;i=e[i].nxt){ if(e[i].w) minhei=min(minhei,hei[e[i].v]); } hei[u]=minhei+1; gap[hei[u]]++; cur[u]=head[u]; } return res; } bfs(); for(int i=1;i<=n;i++) gap[hei[i]]++; while(hei[s]<=n){ for(int i=1;i<=n;i++) cur[i]=head[i]; ans+=ISAP(s,LLINF); }
-
HLPP 算法:待补充
最小割
定义:破坏一些边使 \(s\) 无法流向 \(t\),求破坏的边的容量之和的最小值(形式化地讲,将所有的点划分为 \(S\) 和 \(T\) 两个集合满足源点 \(s\in S\),汇点 \(t\in T\)。定义割的容量 \(c(S,T)\) 为 \(S\) 到 \(T\) 所有边的容量和,使得 \(c(S,T)\) 最小)。
- 求最小割容量:最小割等于最大流,证明。
- 求最小割方案:在残量网络中从源点开始 dfs,只走容量大于 \(0\) 的边,所有能到达的点就是 \(S\) 中的点,因此也能知道 \(T\)。
- 求最小割边数:先求出最小割,把没有流满的边的容量改成 \(+\infin\),流满的边的容量改成 \(1\),跑一遍最大流就可求出最小割边数。如果不用求最小割容量,直接把所有边的容量设成 \(1\) 跑最大流也可以。
补充:
全局(无源汇点)最小割:见下文 Stoer-Wagner 算法。
费用流(最小费用最大流/MCMF)
顾名思义,是以最大流为前提求最小费用。注意,这里的费用指某条边通过单位流量的费用,而不是某条边通过的固定费用。
将 EK 算法中 bfs 增广换为最短路算法(一般用 SPFA,因为涉及负权),建边时建正向边 \((u,v,cap,cost)\),反向边 \((v,u,0,-cost)\)。一轮增广完毕后,设 \(s\) 流向 \(t\) 的实际流量为 \(flow\),将增广路上正向边cap-=flow
,反向边cap+=flow
,更新答案 ans_MaxFlow+=flow
,ans_MinCost+=dis[t]*flow
(其中 dis[x]
指源点到 \(x\) 所经过边 \(cost\) 之和的最小值,在 SPFA 中求出)。
费用流完全可以通过修改 Dinic 而非 EK 求出,待补充
树同构
无根树同构指存在一种两棵树节点的双射 \(F\),使得节点 \(u_1\) 和 \(v_1\) 相连当且仅当 \(u_2=F(u_1)\) 与 \(v_2=F(v_1)\) 相连。而有根树同构还需要在此基础上保证 \(root_2=F(root_1)\)。
判断无根树的同构需要先将无根树转换为有根树,即设其重心为根,再判断有根树同构,有两个重心时要判断两种情况。
以下的两种做法皆在解决有根树同构。
树哈希
单哈希容易被精心构造的数据卡,因此要养成双哈希的好习惯。
常见的树哈希函数如下:
- \(\large f_u=size_u\times\sum\limits_{v\in son_u} f_v\cdot p^{id-1}\),其中 \(p\) 为质数,\(id\) 为将 \(u\) 的子节点按子树大小排序的排名。
- \(\large f_u=1+\sum\limits_{v\in son_u} f_v\cdot prime[size_v]\),其中 \(prime[i]\) 表示质数表中第 \(i\) 个质数。
AHU 算法
我们知道,在确定某种遍历顺序时,一棵树有唯一的括号序,而这如果将遍历顺序设为按照儿子节的括号序从小到大时,可以发现这样儿子之间的排名是不会重复的。但是这样的缺点是生成出的括号序列可能会非常大,我们发现其实每一层的括号序排名只和其下一层的节点有关,因此当我们将下一层的括号序
(string)
压缩成他们的排名(int)
时,再组合起来其父节点的排名仍然不变。于是这就催生出了 AHU 算法。
从低到高处理每一层,当处理到 \(i\) 层的时候,先把每个节点所有儿子的排名组合成序列,然后 \(i\) 层进行排序(注意此处是两棵树同层一起排),计算出每个节点的排名并替换掉序列,最后两棵树同构当且仅当根节点排名相同。代码实现时,两棵树存在同一张图里较为方便。
Code
int dep[MAXN<<1],fa[MAXN<<1],rk[MAXN<<1];
vector<int>layer[MAXN],sonseq[MAXN<<1];
void dfs(int x,int fat){
fa[x]=fat;
dep[x]=dep[fat]+1;
layer[dep[x]].push_back(x);
for(auto it:g[x]){
if(it==fat) continue;
dfs(it,x);
}
}
inline bool AHU(int rt0,int rt1){
for(int i=1;i<=n+1;i++) layer[i].clear();
for(int i=1;i<=2*n;i++) sonseq[i].clear(),rk[i]=0;
dep[0]=0;
dfs(rt0,0);dfs(rt1,0);
for(int d=n;d>=1;d--){
for(auto it:layer[d+1]){
sonseq[fa[it]].push_back(rk[it]);
}
sort(layer[d].begin(),layer[d].end(),[&](const int &a,const int &b)->bool{return sonseq[a]<sonseq[b];});
int cnt=0;
for(int i=0;i<layer[d].size();i++){
if(i>0 && sonseq[layer[d][i]]!=sonseq[layer[d][i-1]]) cnt++;
rk[layer[d][i]]=cnt;
}
}
return rk[rt0]==rk[rt1];
}
//注意此时两棵树是存在同一张图里的
cin>>n;
for(int i=1,u,v;i<n;i++){
cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
for(int i=1,u,v;i<n;i++){
cin>>u>>v;
g[u+n].push_back(v+n);
g[v+n].push_back(u+n);
}