图论知识总结
图论
参考
oiwiki 网上博客
树
LCA
性质
- 一堆点集的LCA 等于其中 dfn 最大的和最小的点的 LCA
dfs序求lca
好写且
如果两个点
和 不存在祖孙关系,那么 。
我们钦定
考虑证明很简单,思考dfn是怎么来的即可。
如果存在祖孙关系,那么
代码:
int dfn[N], tim, st[21][N];
void dfs(int u, int pre) {
st[0][dfn[u] = ++tim] = pre;
for(int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to;
if(v == pre) continue;
dfs(v, u);
}
}
int Min(int x, int y) { return dfn[x] < dfn[y] ? x : y; }
void init() {
for(int i = 1; i <= 20; i++)
for(int j = 1; j + (1 << i) - 1 <= n; j++)
st[i][j] = Min(st[i - 1][j], st[i - 1][j + (1 << i - 1)]);
}
int lca(int x, int y) {
if(x == y) return x;
if((x = dfn[x]) > (y = dfn[y])) swap(x, y);
int d = 31 ^ __builtin_clz(y - x); x++;
return Min(st[d][x], st[d][y - (1 << d) + 1]);
}
实际实现不需要dep和fa数组,具体细节见代码。
这个做法的瓶颈在RMQ,如果用一些高科技搞RMQ可以做到
树剖求lca
树的直径
两种方法:两次dfs或者树形DP。推荐第二种,因为好写且支持找负边权的直径,树形DP又有两种方法,这里写一种好写的。
不过有意义的,两次dfs的结论也可以记住:
在都是非负边权的情况下,从树上任意一点
出发,到达的最远的点 一定是直径的一端。
具体证明很简单,使用反证法,分三种情况分别考虑
就在直径上 不在直径上,但是求得的路径 与直径 有交点。- 没有交点。
DP代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 1e4 + 10;
int n, dp[N], ans;
void chmx(int &x, int y) { x = max(x, y); }
vector<int> G[N];
void dfs(int u, int fa) {
for(int v : G[u]) if(v ^ fa) {
dfs(v, u);
chmx(ans, dp[u] + 1 + dp[v]);
chmx(dp[u], 1 + dp[v]);
}
}
int main() {
scanf("%d", &n);
for(int i = 1, u, v; i < n; i++)
scanf("%d%d", &u, &v), G[u].push_back(v), G[v].push_back(u);
dfs(1, 0);
printf("%d", ans);
return 0;
}
动态维护直径
树的重心
性质
- 重心最多两个,如果有两个,那么肯定相邻。
- 以树的重心为根时,所有子树的大小都不超过整棵树大小的一半。(这个也是充要条件)
- 树中所有点到某个点的距离和中,到重心的距离和是最小的;如果有两个重心,那么到它们的距离和一样。(这个性质也挺好)
- 要是通过一条边把两棵树连起来,那么新的重心在原来两棵树的重心的路径上。(有助于动态维护重心)
- 在一棵树上添加或删除一个叶子,那么它的重心最多只移动一条边的距离
动态维护树的重心
证明: 先咕着。
树剖
长剖和实剖先不展开。主要就是重链剖分。
重链剖分及其擅长解决树上路径问题,他把树剖成了若干条重链,并且任意一条路径上最多
顺带着dfs序的性质,树剖对于子树信息的维护也很擅长。
具体算法内容就不赘述了,这里只做总结。
dsu on tree
也叫树上启发式合并,有些时候我们完全可以直接启发式合并,但是这个算法给了另一种启发式合并的思路,dsu on tree主要是对子树信息统计,具体算法很人类智慧,考虑我们要统计每个点的子树信息,并且只能用一个桶或者数据结构。
比如我们dfs目前到了节点
考虑证明:计算每个点会被统计几次,如果他在重链上,那么他的信息会被继承上去,不会重复统计,所以说一个点的统计次数就等于这个点到根的重链数量,也就是
虚树
虚树适用于某些题目,这些题目通常需要你做很多次树形DP,只有少部分点是我们DP需要的,我们可以根据他们的祖孙关系建出虚树,这样复杂度就降下来了。
结论1:一个集合的LCA 等于集合里dfn最大的和dfn最小的两点的LCA。
结论2:将一个集合的点按dfn排序得到
, 中一定出现等于 的点。
我们把关键点取出来,按dfn排序,取出相邻两点的LCA,然后去重,得到的这些点根据祖孙关系建树的话一定可以连通。由结论1和结论2可以知道,任意一个区间的LCA,都会在相邻两点的LCA中出现。所以这些点一定够。
需要哪些点知道了,接下来就只需要建树,把这些点再按dfn排序,遍历这个数组,遍历所有相邻的数
考虑证明:
- 如果
与 存在祖孙关系,那 肯定是祖先,并且他们之间不会有其他点, 连向了 。这是正确的。 - 如果不存在,那么就来自不同的子树,考虑
到 的路径上肯定没有其他点,这么连也是对的。
考虑上面一共连了
树分治
这个内容主要是题目,并且很熟了,就不写了。
点分治
边分治
点分树
树哈希
树上随机游走
矩阵树定理
拓扑排序
DAG可以DP。根据拓扑序来DP没有后效性。所以把限制看成边,把问题转化成图很关键。
最小生成树
Kruskal
这个算法人人都会吧,是个贪心算法,但是正确性还是有待证明(比如有多条边权值一样,那我选哪些,选了这些边很可能对树的形态造成影响导致后面选的不优)。所以所以这里证明一下实际上没有影响,就是能选就选:
我们现在只需要证明任意时刻,我们选的边集一定可以被某一个MST包含即可。
我们设目前选的边集为
- 如果
包含于 ,那么就显然成立。 - 如果不包含,那么
在 中一定形成了一个环,并且这个环上不可能有比他大的,不然 就可以替换其中一条大边成为一个新的MST,这不符合定义,并且不存在未被选的比 小的边,这个显然正确,如果存在,那么在之前就会被选,所以环上只会存在若干条和 一样大的边,这是我们可以随便拆一条边,然后把 放进去,设其为 ,那么我就令 为新的MST。
这就引出了生成树的唯一性。
生成树的唯一性
考虑要看生成树是不是唯一,就是上面kruskal的证明过程。
具体实现就是考虑所有权值相同的颜色段,扫描到一个颜色段后,先看有多少个能放进去得到一个预估放的数量,然后一个一个放,得到实际放的数量,如果二者不相等,就说明一定有边形成了环,而且环上有权值相同且已经被选。所以就可以拿目前这个边去替换那个权值相同,根据前文的证明,替换了不会影响答案。
Boruvka
就是每个连通块同过最小边往外合并,初始时每个点为一个连通块,每一轮连通块减半,所以最多
关于正确性的证明暂时还不会...网上也没找到资料。
瓶颈生成树
定义:最大边在所有生成树中最小。
一般我们要求最小瓶颈生成树,但是这个我们不会求,考虑最小生成树一定是瓶颈生成树,求最小生成树就很简单了。
证明:如果一个MST
最小瓶颈路
定义:
结论:最小生成树上两点路径就是一条瓶颈路。
证明:
构造性证明: 假设
反证法: 仍然假设
kruskal重构树
原图中两个点之间的所有简单路径上最大边权的最小值 = 最小生成树上两个点之间的简单路径上的最大值 = Kruskal 重构树上两点之间的 LCA 的权值
这些奇妙的性质搭配数据结构就很方便。
kruskal的过程中, 每次合并两个连通块时, 新建一个节点, 点权为边权, 将两个连通块的根节点作为新点的左右儿子。
原图中两个点之间的所有简单路径上最大边权的最小值 = 最小生成树上两个点之间的简单路径上的最大值 = Kruskal 重构树上两点之间的 LCA 的权值。
也就是两点之间最小瓶颈路就是他们的 lca 的点权。
到点
最小斯坦纳树
最小树形图
最小直径生成树
最短路算法
Floyd
这个算法可以求全源最短路, 不关无向有向, 不管边权正负, 但是不能有负环。
考虑 Floyd 就是 DP, 我们设
然后滚动数组优化一下空间。
拓展
用 Floyd 求正权无向图的最小环。
我们仍然套用Floyd的状态和方程。
考虑这肯定是一个简单环, 因为是正环, 构造性证明即可。 然后显然我们可以把每一个简单环映射在每个简单环上的最大编号的节点, 这样我们枚举最大编号的节点就可以考虑完所有的环, 然后我们就要枚举 最大编号的的节点
。
Bellman–Ford
这个算法可以求有负边权的图的最短路, 还可以判断负环。 主要是基于松弛操作。 我在初学最短路算法时有个最大的疑惑就是为什么松弛操作要叫做 “松弛”操作, 现在知道了, 本来是 relax , 考虑要满足
算法: 考虑最短路最长有多少条边,
判断负环: 考虑如果有负环, 那么松弛操作就永远不会停止, 所以如果
需要注意的是, 如果只是从
优化
队列优化(SPFA):
考虑人类智慧, 比如第一轮松弛操作时, 显然我们可以只松弛与
时间复杂度:
优先队列优化 :
考虑人类智慧, 队列中更小的放前面, 可以省去一些非松弛操作, 但是会带来
双端队列优化:
考虑比对头小的就放入队头, 否则放入队尾。
考虑SPFA复杂度玄学, 我觉得最有用的就是 dinic 跑费用流时好用, 如果想用dinic过大数据可以把各种优化都试一试。
Dijkstra
最常用的最短路算法了, 可以求非负边权图的最短路, 时间复杂度
算法: 我们定义两个集合
显然, 每个点遍历了他的所有出边, 取出最小值可以用优先队列, 时间复杂度
正确性证明: 很明显, 我们只需要证明, 每次从
例题
P4568 [JLOI2011] 飞行路线
分层图, 其实我更愿意用DP的思想去理解, 这样更加的通用, 我们设
[ABC077D] Small Multiple
同样可以用DP的思想, 我们发现是
CF786B Legacy
考虑要单点对区间, 区间对单点, 考虑套用线段树的结构, 我们开两个线段树, 然后把叶子节点之间连边权为 0 的边, 这样就相当于同一个点了。
P6348 [PA2011] Journeys
考虑区间对区间, 和上面的没什么区别, 只是搞个虚点又可以减少边数。
P9520 [JOISC2022] 监狱
最短路树和图
考虑最短路图就是所有满足
CF1076D Edge Deletion
板子题
P6880 [JOI 2020 Final] 奥运公交
考虑数据范围
同余最短路
可以求出有多少数值可由给定数的系数非负线性组合得到。
一般用SPFA更快, 因为建图特殊。
差分约束
差分约束问题通常有多个形如
差分约束的解决依赖于三角形不等式,即
在单源最短路中,如果有边
是
找负环代码:
bool Bellman_Ford() {
for(int k = 1; k < n; k++) {
bool fl = 0;
for(int u = 1; u <= n; u++)
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(dis[v] > dis[u] + E[i].dis)
dis[v] = dis[u] + E[i].dis, fl = 1;
}
if(!fl) return 1;
}
for(int u = 1; u <= n; u++)
for(int i = head[u]; i; i = E[i].next)
if(dis[v] > dis[u] + E[i].dis)
return 0;
return 1;
}
bool SPFA() {
queue<int> q;
for(int i = 1; i <= n; i++) q.push(i), ins[i] = 1;
while(!q.empty()) {
int u = q.front(); q.pop();
ins[u] = 0;
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(dis[v] > dis[u] + E[i].dis) {
dis[v] = dis[u] + E[i].dis;
cnt[v] = cnt[u] + 1;
if(cnt[v] == n) return 0;
if(!ins[v]) q.push(v), ins[v] = 1;
}
}
}
return 1;
}
P5590 赛车游戏
考虑我们要满足边权为
连通性相关
强连通分量
定义:
- 树边,dfs树上的边。
- 横叉边,指向一个访问过的点,但不是这个点的祖先和孙子。
- 反祖边,指向一个访问过的点,这个点是他的祖先。
- 前向边,指向一个访问过的点,这个点是他的孙子。
结论1:若
是某个强连通分量遇到的第一个点,那么强连通分量中的其他点一定在 的子树中。
证明:如果遇到一个
算法:
我们完全可以根据
- 如果
没被访问过,直接搜下去。然后根据定义 。 - 如果
访问过且在栈中,那么这个肯定是反祖边,根据定义 。 - 如果
访问过且不在栈中,直接不管他, 已经处理过了。但是这里不能用来更新 ,因为这是有向图 不在栈中,说明他们不连通,所以更新了错误的信息。
根据上述过程就可以更新到每个点的
考虑到一个强连通分量的所有点,他们肯定可以回溯到最上面的那个属于强连通分量的那个点,也就是第一个遇到的。所以我们可以得出,这个强连通分量中有且仅有一个点使得
结论2:如果
,就说明这个点是强连通分量的第一个点,栈中剩下的就都是这个强连通分量的点。
因为我们的递归函数已经把其他强连通分量的筛掉了。因为我们有第一个结论,强连通分量中其他的点一定在子树中,所以递归完就把其他的强连通分量解决了,栈中
void Tarjan(int u) {
low[u] = dfn[u] = ++tim;
stk[++top] = u, ins[u] = 1;
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(!dfn[v]) {
Tarjan(v);
low[u] = min(low[u], low[v]);
} else if(ins[v])
low[u] = min(low[u], dfn[v]);
}
if(low[u] == dfn[u]) {
sc++;
do {
ins[stk[top]] = 0;
scc[sc].push_back(stk[top]);
} while(stk[top--] != u);
}
}
双连通分量
这个是针对无向图的。
定义:
边双连通:
点双连通:
简单地:边双连通有传递性,点双连通没有传递性。
点双连通分量
边双连通分量
树边:在 dfs 搜索树上的边。
返祖边:除了树边的边都是反祖边。
证明:如果
边双连通分量
考虑这玩意就是
与
注意:
- 边双中重边是有意义的,所以我们防止回到父亲的时候就不能单纯记录一个
,而是要记录边。 - 自环要判掉。
void Tarjan(int u, int pre) {
low[u] = dfn[u] = ++tim, stk[++top] = u;
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(i == (pre ^ 1)) continue;
if(!dfn[v]) {
Tarjan(v, i);
low[u] = min(low[u], low[v]);
} else
low[u] = min(low[u], dfn[v]);
}
if(low[u] == dfn[u]) {
++edc;
do edcc[edc].push_back(stk[top]);
while(stk[top--] != u);
}
}
点双连通分量
结论:如果一个
的第一个访问到的点为 ,那么这个 的其他点一定在他的子树内,并且这个点一定是割点。
证明:这个
所以算法就很简单了,
注意:点双需要判断自环,在找孤立点的时候有自环就会锅掉。
void Tarjan(int u) {
low[u] = dfn[u] = ++tim, stk[++top] = u;
if(u == rt && !head[u]) vdcc[++vdc].push_back(u);
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(!dfn[v]) {
Tarjan(v);
low[u] = min(low[u], low[v]);
if(low[v] == dfn[u]) {
vdc++;
do vdcc[vdc].push_back(stk[top]);
while(stk[top--] != v);
vdcc[vdc].push_back(u);
}
} else
low[u] = min(low[u], dfn[v]);
}
}
为了代码和前面的代码更像更简单,我们不写 else if(v != fa)
,并且把判定改成 low[v] == dfn[u]
。
割点和桥
割点
了解了前面的定义,我们就可以很轻易的得出结论:
当一个点
满足其某一个儿子 , ,那么 是割点。
证明:很简单,删掉
注意:根节点需要特判,如果根节点有至少两个子树,那么就一定是割点。
void Tarjan(int u) {
low[u] = dfn[u] = ++tim;
int son = 0;
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(!dfn[v]) {
Tarjan(v);
low[u] = min(low[u], low[v]);
son++;
if(dfn[u] == low[v]) cut[u] = 1;
} else
low[u] = min(low[u], dfn[v]);
}
if(u == rt && son < 2) cut[u] = 0;
}
这里用 dfn[u] == low[v]
是因为我没有判断
桥
当一个点
满足且某一个儿子 , ,那么边 是桥。
void Tarjan(int u, int fa) {
low[u] = dfn[u] = ++tim;
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(!dfn[v]) {
Tarjan(v, u);
low[u] = min(low[u], low[v]);
if(dfn[u] < low[v]) cut[i] = 1;
} else if(v != fa)
low[u] = min(low[u], dfn[v]);
}
}
这里就必须判断 v != fa
了。
圆方树
圆方树就是求
void Tarjan(int u) {
low[u] = dfn[u] = ++tim, stk[++top] = u;
if(u == rt && !head[u]) add(++vdc, u);
for(int i = head[u]; i; i = E[i].next) {
int v = E[i].to;
if(!dfn[v]) {
Tarjan(v);
low[u] = min(low[u], low[v]);
if(low[v] == dfn[u]) {
vdc++;
do add(vdc, stk[top]);
while(stk[top--] != v);
add(vdc, u);
}
} else
low[u] = min(low[u], dfn[v]);
}
}
显然这么连边会构成一棵树,我们可以将
环计数问题
2-SAT
感觉和差分约束挺容易搞混的,差分约束是求若干不等式限制的一组解。2-SAT 解决的问题是给定若干个集合,每个集合大小为 2,代表一个限制,这两个元素不能共存,给出一个选择方案,从每个集合选出一个数,使得满足所有的限制条件。
我们用边可以表示很多信息,比如
从例题出发。
P5782
定义
我们就会得到一个一般图,很容易可以得出如果我们缩点后,
所以我们就会判断有无解了,那我们怎么输出一组可行解呢。
考虑我们缩点以后,这是一个 DAG,显然如果
欧拉回路
定义:
欧拉路径: 经过连通图中所有边恰好一次的迹是欧拉路径。
欧拉回路: 经过连通图中所有边恰好一次的回路是欧拉回路。
欧拉图: 有欧拉回路的图是欧拉图。
半欧拉图: 有欧拉路径没有欧拉回路的图是半欧拉图。
判定:
有向图是欧拉图, 当且仅当图弱连通且所有点出度等于入度。
有向图是半欧拉图, 当且仅当图弱连通且仅有两个点入度等于出度减一和加一, 其他点出度等于入度。
无向图是欧拉图, 当且仅当图连通且所有点度数都为偶数。
无向图是半欧拉图, 当且仅当图连通且仅有两个点度数为奇数, 其他点度数都为偶数。
算法
找欧拉回路从一个点
如果找欧拉路径则是从出度等于入度加1的点开始或者某个度数为奇数的点开始即可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!