图论基础1
图论基础预备知识
概念:图(边的有向/无向、边权)、连通分量、连通图、环、环的性质
邻接矩阵建图(一般不用)、邻接表建图(vector实现)、链式前向星建图(数组/struct实现)、基础
三种建图方式的代码实现:
邻接矩阵
int dis[N][N];
for (int i = 1; i <= m; i ++) {
cin >> a >> b >> c;
dis[a][b] = c; // 如果a,b之间有不止一条边,则dis[a][b]的值要具体问题具体讨论
}
邻接表
vector<pair<int, int> > edges[N];
for (int i = 1; i <= m; i ++) { // 建图
cin >> a >> b >> c;
edges[a].push_back(make_pair(b, c));
}
for (int i = 1; i <= n; i ++) // 查询
for (int j = 0; j < edges[i].size(); j ++) {
to = edges[i][j].first;
w = edges[i][j].second;
}
链式前向星
int head[N], cnt;
struct Edges{
int to, w, nxt;
} edges[M << 1];
void add_edge(int from, int to, int w) { // 建图
edges[++ cnt].to = (Edges){to, w, head[from]};
head[from] = cnt;
}
for (int i = 1; i <= m; i ++) {
cin >> a >> b >> c;
add_edge(a, b, c);
}
for (int i = 1; i <= n; i ++) // 查询
for (int j = head[i]; j; j = edges[j].nxt) {
to = edges[j].to;
w = edges[j].w;
}
树的概念与性质
以下所考虑的所有图都默认没有重边和自环、只有无向边,即简单无向图。
树的等价描述
命题1:有
证明:每条边最多将两个不连通的部分变成连通的,所以最多减少一个连通块。
定义1:对于一个有
命题2:树
命题3:对于一个连通图,树
证明:
命题4:树
证明:
树与图
首先先明确:树是一种特殊的图,进一步地,树是一种特殊的无向连通图。
命题5:任意一个不是树的连通图一定存在一条边,去掉它之后仍然是连通图。
证明:由命题3知该连通图中存在至少一个环,去掉该环上的任意一条边即可。
定义2:一个连通图如果能去掉几条边(可以为0条)变成一棵树,则这颗树称为该连通图的生成树。(显然一个连通图的生成树不一定唯一)
命题6:任意一个连通图都存在生成树。(由命题2和命题5易得)
有根树与无根树
作为一个图而言,树的任何一个点都可以是根,确定根以后就可以确定节点的父子关系。确定了根节点的树称为有根树,否则称为无根树。显然,一个节点有编号的(无根)树有
树的遍历与dfs序
现在考虑一个已经确定了根的树,从根开始沿着已有的边做
通过
需要考虑的一个问题是,实际问题中我们往往遇到的是无根树,那随意确定一个根开始的
树上
int dep[N], siz[N], dfn[N], fa[N], num, maxsiz[N];
// 以维护深度、子树大小、dfs序、父节点、最大的儿子子树的大小为例
void dfs(int now, int father) {
dep[now] = dep[father] + 1;
siz[now] = 1;
dfn[now] = ++ num;
fa[now] = father;
for (int i = head[now]; i; i = edges[i].nxt)
if (edges[i].to != father) {
dfs(edges[i].to, now);
siz[now] += siz[edges[i].to];
maxsiz[now] = max(maxsiz[now], siz[edges[i].to]);
}
return;
}
dfs(1, 0); // 以1为根开始dfs
关于
有一些题可以利用这个性质将树上的问题转化为序列上的问题来解决。
例题
P5908 猫猫和企鹅:树的遍历求节点深度
P2420 让我们异或吧:树的遍历求异或前缀和
P1351 [NOIP2014 提高组] 联合权值:
最近公共祖先 LCA
现在考虑一棵有根树,树上任意两个点之间的简单路径怎么找呢?如果这两个点中的一个是另一个点祖先,则从那个后代一路向上就可以到达这个点。如果两个点都不是对方的祖先呢,沿用刚才的想法,可以从一个点到达公共的祖先,再从这个公共祖先到达另一个点。由于有简单路径的要求,所以我们要找到距离两个点最近的公共祖先,也就是深度最小的公共祖先,最近公共祖先简称 LCA(Lowest Common Ancestor)。完全一样地,我们也可以定义一个点集的最近公共祖先。
LCA有以下几条性质:
命题1:
命题2:
命题3:两点集并的LCA为两点集分别的LCA的LCA。
命题4:两点的LCA在两点之间的最短路(唯一)上。
命题5:
倍增求LCA
求LCA的方法主要有倍增和树剖。倍增的时间复杂度是
先考虑朴素的求
- 如果
a 和b 的深度不同,就让深度大的那一个(假设为a )不断做a=fa[a] 直到深度相同 - 如果此时
a==b 就说明b 是原本的a 的祖先,b 即是LCA - 如果此时
a!=b 就不断做a=fa[a],b=fa[b] 直到a==b ,此时即是LCA
这样做单次查询的复杂度是
倍增的思想你们都学过了,比如凑出19(二进制为10011)的重量,只需要重量为16、2和1三个砝码就够了,而只需要重量为
而用倍增的思想优化朴素方法中的一步一步跳就是用若干个一次跳
预处理可以采用递推的方法,一方面按
代码实现:
预处理
int n, rt, dep[N], fa[N][30]; // rt:根节点
vector<int> edges[N];
void dfs(int x) {
for (int i = 0; i < edges[x].size(); i ++) {
int y = edges[x][i];
if (dep[y]) continue;
dep[y] = dep[x] + 1, fa[y][0] = x;
for (int k = 1; (1 << k) <= n; k ++) // 按k从小到大递推
fa[y][k] = fa[fa[y][k - 1]][k - 1]; // 利用递推关系直接得到
dfs(y); // 按dfs顺序递推,处理完y再处理y的后代
}
}
void get_fa_dfs() {
dep[rt] = 1;
dfs(rt);
}
查询
int Lca(int a, int b) {
if (dep[a] < dep[b]) swap(a, b); // 让a是深度大的那一个,让a往上跳
for (int k = 25; k >= 0; k --)
if (dep[fa[a][k]] >= dep[b]) a = fa[a][k]; // a跳到和b同一个高度
if (a == b) return b; // 如果a==b就说明b是原来的a的祖先,返回b即可
// 如果a!=b就让a和b同时往上跳,直到跳到一块
for (int k = 25; k >= 0; k --)
if (fa[a][k] != fa[b][k]) a = fa[a][k], b = fa[b][k];
// fa[a][k]==fa[b][k]相当于需要凑的步数的二进制表示下第k位为0,不跳,不等于的时候才跳
// 这样处理会使得我们永远也跳不到LCA,因为只凭fa是否相等无法判断跳到的那个点是LCA还是LCA的祖先,所以我们跳的终点实际上是LCA的下面一个点
return fa[a][0]; // 输出的时候也要输出跳的终点的上面一个点
}
例题
树上问题大都离不来LCA,只要提到树上两点之间的路径就需要LCA,而考虑树上一段路径上的问题的题很常见(处理这类题还有一个很常用的以后会学的方法是树链剖分)。树上差分本质上也是利用了树上两点之间的路径,所以也需要用到LCA。
另外,很多题目就算没有明着提到树上两点之间的路径,但是会涉及到树上的距离,从而就也需要考虑LCA。
P3379 【模板】最近公共祖先(LCA):板子
P5836 [USACO19DEC] Milk Visits S:倍增求LCA的时候同倍增数组一样地多维护一个“该跳跃区间是否存在某奶牛的数组”
P4281 [AHOI2008] 紧急集合 / 聚会:求出三个点两两LCA中只出现一次的那个LCA即是集合点,利用命题5计算距离即可(考虑证明)
树的直径
定义1:树上的最长链为树的直径。
有时也简称树的直径的长度为树的直径,显然,树的直径不一定唯一,但树的直径的长度一定唯一。
树的直径的题目中多是需要想出结论才能做的(就连两遍
树的直径有以下几条性质:
命题1:树的直径的端点一定是叶节点(度数为1)。
命题2:对于边权为1的树,如果树的直径不止一条,则直径的长度一定为偶数,且所有直径的中点重合。(考虑反证)
命题3:边权为正的树,如果树的直径不止一条,则所有直径的中点重合,并且中点延伸出的任意直径的一半长度均相等。
命题4:以任意一点为根开始
两遍dfs 求树的直径
先以任意点为根先
代码实现:
int n, dep[N], s, maxdep;
vector<int> edges[N];
void dfs(int now, int fa) {
for (int i = 0; i < edges[now].size(); i ++) {
int y = edges[now][i];
if (y == fa) continue;
dep[y] = dep[now] + 1;
maxdep = max(maxdep, dep[y]); // maxdep是dfs找到的最大深度
dfs(y, now);
}
}
dfs(1, 0);
for (int i = 1; i <= n; i ++)
if (dep[i] == maxdep) s = i; // s是第一遍dfs找到的最远点
dep[s] = 0; // 根节点的深度为0,从而深度就是到根节点的距离
dfs(s, 0);
printf("%d", maxdep);
树形dp 求树的直径
用树形
代码实现:
int n, dep1[N], dep2[N], maxdep;
vector<int> edges[N];
void dfs(int now, int fa) {
for (int i = 0; i < edges[now].size(); i ++) {
int y = edges[now][i];
if (y == fa) continue;
dfs(y, now);
if (dep1[y] + 1 > dep1[now]) {
// y的子树内只能贡献now的至多一条长链,所以要用dep1[y],先和dep1比较,再和dep2比较
dep2[now] = dep1[now];
dep1[now] = dep1[y] + 1;
}
else if (dep1[y] + 1 > dep2[now])
dep2[now] = dep1[y] + 1;
}
}
dfs(1, 0);
for (int i = 1; i <= n; i ++)
maxdep = max(maxdep, dep1[i] + dep2[i]);
printf("%d", maxdep);
两种方法比较
两遍
树形
实际做题中需要根据题目选择使用哪个方法。
例题
PT07Z - Longest path in a tree:板子
P2195 HXY造公园:树的直径的定义,求出初始树的直径初始化并查集,然后做并查集合并、查询操作即可
P5536 【XR-3】核心城市:树的直径的性质+树上
P1099 [NOIP2007 提高组] 树网的核:贪心求直径上距两端距离最大值最小的一段即可(考虑证明)
P4408 [NOI2003] 逃学的小孩:贪心,AB为直径两端,C遍历着找即可(考虑证明贪心结论)
P3629 [APIO2010] 巡逻:第一条路建直径两端,第二条路将第一条路上权改为-1后树形dp找出直径(分别考虑证明第一条路和第二条路正确性)
树的重心
对于一个无根树,子树的含义为去掉该点后分成的若干个连通块(均是树)。
定义1:无根树上最大子树最小的点是树的重心。
显然,树的重心一定存在但不一定唯一,重心的最大子树的大小唯一。(例:两个点的树)
树的重心的题目和树的直径的题目一样,需要使用结论,下面列出几条常用的性质。在证明有关树的重心的结论的时候,也是采用反证法,构造出更优的“重心”或者说明不存在更优的“重心”。
命题1:树的重心
证明:
命题2:如果树的重心不唯一,则只有以下一种情形:,即有且仅有相邻的1,2两个点是树的重心,断掉两点之间的边之后为两个相同大小的树。
证明:以一个重心
命题3:用一条边将两棵树连接起来,新的重心在原来分别的重心的连线上。
证明:只证明两棵树重心都唯一的情形,其他情形由该情形下的证明易证得。对于分别的重心连线以外的点(不妨设该点属于树1),以该点为根则树1的重心和树2全部在同一棵子树内,而其他子树大小总和都小于树1大小的一半,从而包含树1重心的那棵子树的大小大于树1加树2大小总和的一半。
命题4:如果一个树增添或删去一个叶子,则树的同一个重心最多移动一个节点。(证明不难,留作思考)
命题5:树中所有点到某个点的距离和中,到重心的距离和是最小的。
证明:调整法,以树的重心
注:以上的定义和命题均是对点权为1、边权为1的树讨论的,对于点权和边权为任意正数的树,定义和命题1、2、3、5都可完全一致地得到(同样的证明)(此时的子树大小
求树的重心
由命题1,要求树的重心只需要从任意点开始
代码实现:
int n, siz[N], maxsiz[N], ans;
vector<int> edges[N];
void dfs(int now, int fa) {
siz[now] = 1;
for (int i = 0; i < edges[now].size(); i ++) {
int y = edges[now][i];
if (y == fa) continue;
dfs(y, now);
maxsiz[now] = max(maxsiz[now], siz[y]);
siz[now] += siz[y];
}
if (max(maxsiz[i], n - siz[i]) * 2 <= n) // 判断是不是重心
printf("%d ", i);
}
例题
P1395 会议:板子
P1364 医院设置:带点权的树的重心,确定重心之后
Kay and Snowflake:第二次
Link Cut Centroids:若重心不唯一,删一个叶节点加到另一个重心上即可(考虑重心不唯一的树的结构、考虑证明这样做的正确性)
树上差分和前缀和
在让我们异或吧这道题中我们已经使用过树上前缀和的思想了,类比序列差分和前缀和,树上差分和树上前缀和解决的问题是对于树上的一段路径(对应序列里的区间)的修改和查询。
树上差分:路径加上同一个数:
树上前缀和:路径加上同一个数:
树上前缀和
考虑维护树上的点权,每一个点
初始化:
如果维护的是树上的边权,用边的深度较大的那个节点的点权来记录边权即可。注意此时查询
树上差分
考虑维护树上的点权,每一个点
初始化:
查询任意点的点权:
如果维护的是树上的边权,用边的深度较大的那个节点的点权来记录边权即可。注意此时
代码实现:(点差分板子Max Flow P)
void get_sum_dfs(int x, int F) {
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i];
if (y == F) continue;
get_sum_dfs(y, x);
point[x] += point[y];
}
ans = max(ans, point[x]);
}
for (int i = 1, x, y; i <= m; ++i) {
x = read(), y = read();
int t = lca(x, y);
++ point[x], ++ point[y];
-- point[t], -- point[fa[t][0]];
}
例题
P3128 [USACO15DEC] Max Flow P:点差分板子
P3258 [JLOI2014] 松鼠的新家:点差分板子,第一条路径首尾都加1,最后一条路径首尾都不加,其他路径尾加首不加
P6869 [COCI2019-2020#5] Putovanje:边差分板子,每条路径上的边加1,
P8805 [蓝桥杯 2022 国 B] 机房:树上前缀和板子
模拟赛
P2052 [NOI2011] 道路修建:黄,树的遍历
P4427 [BJOI2018] 求和:绿(实际难度黄到绿),树上前缀和。
P6374 「StOI-1」树上询问:蓝(实际难度为绿),LCA
P2491 [SDOI2011] 消防:紫(但是是讲过的原题),树的直径。题解参考树网的核