[图论入门]点分治
#1.0 点分治
#1.1 简单介绍
点分治 是一种解决树上统计问题的常用方法,本质思想就是选择一点作为分治中心,将原问题划分为几个相同的子树上的问题,进行递归解决。
常见题目中给出的树都是无根树(所需维护的信息与根节点是谁无关)。
常见的用于统计树上有关路径的信息。假设当前选定根节点为 \(rt\),则符合条件的路径必然是以下两种之一:
- 经过 \(rt\) 或一端在 \(rt\) 上;
- 不经过 \(rt\),在 \(rt\) 的子树上。
点分治仅仅是一种思想、方法,并没有固定的信息维护、转移方式。
#1.2 树的重心
注意到,点分治是要将问题递归解决的,那么树根的选择就很重要,假如我们的树是一条链,每次递归都会选择链的一端,那么递归层数就达到了 \(O(n)\) 的级别,那么怎么选择根节点呢?
答案是选择当前树的重心作为根节点。
对于树上的每一个点,计算其所有子树中最大的子树节点数,这个值最小的点就是这棵树的重心。而不难证明树的重心具有以下性质:
- 以树的重心为根时,所有子树的大小都不超过整棵树大小的一半。
假如当前点 \(x\) 某个子树的大小超过整棵树的一半,那么选择 \(x\) 的最大子树的根 \(y\) 作为整棵树的根,显然会让最大子树的大小变小,那么点 \(y\) 比之前的 \(x\) 更适合是树的重心。
上面那条性质也就保证了我们的递归层数为 \(O(\log n).\)
#1.3 算法实现
#1.3.1 寻找重心
按照定义寻找即可。
void calcsiz(int x, int fa) {
siz[x] = 1, mx[x] = 0;//初始化
for (int i = head[x]; i; i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v]) {
/*vis[] 具体作用下面会讲*/
calcsiz(e[i].v, x);
siz[x] += siz[e[i].v];
mx[x] = Max(mx[x], siz[e[i].v]);
}
mx[x] = Max(mx[x], sum - siz[x]);
/*不难发现,假如以 x 为根,那么以 x 的 “父亲”
为根的子树大小为整棵树的大小减以 x 为根的树的大小*/
if (mx[x] < mx[rt]) rt = x;
}
#1.3.2 细节
注意到,每一次递归下去时,选择的根节点总是当前子树的重心,但是新的根不一定是之前的根的儿子,比如下面这张图:
第一次找到的根为 \(1\),递归下去后两颗子树上的重心分别为 \(4\) 和 \(5\),都不是 \(1\) 的儿子,所以为了防止重复递归,应当每个节点进行点分治后加上标记,之后的递归不再进入已经打过标记的点,这也就是上面函数中 vis[]
的作用。
#2.0 例题
#2.1 P3806 【模板】点分治1
我们可以先将问题离线下来,一次点分治处理。
考虑一条长为 \(k\) 的路径,假设根为 \(rt\),那么一共有经过 \(rt\) 与不经过两种情况,不经过的情况可以递归处理。
来看经过 \(rt\) 的情况,那么如果在某棵子树中出现了距离 \(rt\) 为 \(l\) 的链,那么如果 \(k-l\) 在其他的子树中出现,那么 \(k\) 就一定可以出现,注意到 \(k-l\) 和 \(l\) 的出现顺序并无影响,所以便可以一棵一棵子树地维护,具体方法是:
- \(\texttt{DFS}\) 处理当前子树上每个点与 \(rt\) 的距离;
- 在 \(\texttt{DFS}\) 的过程中,同时统计有哪些长度出现;
- 与之前子树中出现的边结合,更新答案;
- 将当前子树出现的信息并入。
最后要清空记录的之前子树中出现的边的信息,不要直接使用 memset()
,应用队列保证时间复杂度正确。
之后便递归进入子树进行点分治。
const int N = 100010;
const int INF = 0x7fffffff;
struct Edge {
int u, v, w;
int nxt;
};
Edge e[N];
int n, m, cnt = 1, head[N], qs[N], ans[N];
int rt, sum, siz[N], mx[N], vis[N], tf[10000010];
int dist[N], dd[N], dcnt, q[N], frt, tal;
/*head[], cnt 是存图的,qs[] 存储每一个询问,
ans[] 存储对应问题的结果,rt 是当前子树选定的根,
sum 是当前子树的总大小,siz[x] 是以 x 为根的子树的大小
mx[x] 记录若选择 x 为根,则他的最大子树的大小
vis[] 表示当前点是否已被处理
tf[x] 存储已处理子树上是否有长度为 x 的链
dist[x] 储存 x 距离 rt 的距离,
dd[x] 记录当前子树拥有的链(长度)
dcnt 记录当前子树到 rt 的链的个数,
q[], frt, tal 是队列,用于清空 tf[]*/
template <typename T>
inline T Max(const T a, const T b) {
return a > b ? a : b;
}
inline void add(const int &u, const int &v, const int &w) {
e[cnt].u = u, e[cnt].v = v, e[cnt].w = w;
e[cnt].nxt = head[u], head[u] = cnt ++;
}
void calcsiz(int x, int fa) {
siz[x] = 1, mx[x] = 0; //初始化
for (int i = head[x]; i; i = e[i].nxt) {
if (e[i].v == fa || vis[e[i].v]) continue;
calcsiz(e[i].v,x);
siz[x] += siz[e[i].v];
mx[x] = Max(mx[x], siz[e[i].v]);
}
mx[x] = Max(mx[x], sum - siz[x]);
if (mx[x] < mx[rt]) rt = x;
}
void calcdist(int x, int fa) {
dd[++ dcnt] = dist[x];
for (int i = head[x]; i; i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v]) {
dist[e[i].v] = dist[x] + e[i].w;
calcdist(e[i].v, x);
}
}
void dfz(int x, int fa) {
frt = 0, tal = -1, q[++ tal] = 0;
tf[0] = true, vis[x] = true; //初始化与进行标记
for (int i = head[x]; i; i = e[i].nxt) {
if (e[i].v == fa || vis[e[i].v]) continue;
dist[e[i].v] = e[i].w;calcdist(e[i].v, x);
for (int k = 1; k <= dcnt;k ++)
for (int j = 1; j <= m;j ++) //枚举所有的询问
if (qs[j] >= dd[k]) ans[j] |= tf[qs[j] - dd[k]];
for (int j = 1; j <= dcnt; j ++)
if (dd[j] < 10000010)
q[++ tal] = dd[j],tf[dd[j]] = true;
//观察数据范围可知,询问不会超过 1e7.
dcnt = 0;
}
while (frt <= tal) tf[q[frt ++]] = false;
/*递归进入子树*/
for (int i = head[x]; i; i = e[i].nxt){
if (e[i].v == fa || vis[e[i].v]) continue;
sum = siz[e[i].v], mx[rt = 0] = INF;
calcsiz(e[i].v, x); calcsiz(rt, -1);
dfz(rt, x);
}
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i < n; i ++) {
int u, v, w;scanf("%d%d%d", &u, &v, &w);
add(u, v, w);add(v, u, w);
}
for (int i = 1; i <= m; i ++) scanf("%d", qs + i);
mx[rt = 0] = INF, sum = n;
calcsiz(1, -1); calcsiz(rt, -1); dfz(rt, -1);
for (int i = 1;i <= m;i ++)
if (ans[i]) printf("AYE\n");
else printf("NAY\n");
return 0;
}
#2.2 P4178 Tree
点分治不多说,我们主要来看信息怎么维护。
我们当然可以统计出子树中每个点与 \(rt\) 距离进行维护,但那样太麻烦了,考虑简便一点的方法。
我们可以计算出每个点所属于 \(rt\) 的哪一棵子树 b[x]
,特别的,令 b[rt]=rt
。将所有节点(包括 \(rt\))按照与 \(rt\) 的距离从小到大排序,得到数组 subt[]
,采用双指针 l,r
分别从头尾遍历。如果当前所指的点与 \(rt\) 距离和大于 \(k\),那么就让 r --
,因为此时 l
右侧的点与 \(rt\) 的距离只会更大,直到 dist[subt[l]] + dist[subt[r]] <= k
,那么此时 l+1
到 r
这一段的点都是与 l
所指的点与 \(rt\) 的距离和小于等于 \(k\) 的,但是其中可能有与 \(l\) 所指的节点在同一子树内的,这种情况不合法,应当舍去,所以我们应当维护 cnt[x]
表示在区间 \([l + 1,r]\) 中以 \(x\) 为根的点的数量,更新的答案即为 r - l - cnt[subt[l]]
.
const int N = 100010;
const int INF = 0x7fffffff;
struct Edge {
int u, v, w;
int nxt;
};
Edge e[N];
int n, m, cnt = 1, head[N], dcnt, res;
int siz[N], mx[N], rt, sum, vis[N], dist[N];
int b[N], subt[N], scnt[N];
/*head[], cnt 是存图的,rt 是当前子树选定的根,
sum 是当前子树的总大小,siz[x] 是以 x 为根的子树的大小
mx[x] 记录若选择 x 为根,则他的最大子树的大小
vis[] 表示当前点是否已被处理,dist[x] 储存 x 距离 rt 的距离,
dcnt 记录当前子树节点个数,b[x] 存储 x 属于 rt 的哪棵子树
subt[] 存储子树节点,scnt[x] 是上文中的 'cnt'*/
template <typename T>
inline T Max(const T a, const T b) {
return a > b ? a : b;
}
template <typename T>
inline T Min(const T a, const T b) {
return a < b ? a : b;
}
inline int cmp(const int &a, const int &b) {
return dist[a] < dist[b];
}
inline void add(const int &u, const int &v, const int &w) {
e[cnt].u = u, e[cnt].v = v, e[cnt].w = w;
e[cnt].nxt = head[u], head[u] = cnt ++;
}
void calcsiz(int x, int fa) {
siz[x] = 1, mx[x] = 0;
for (int i = head[x]; i; i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v]) {
calcsiz(e[i].v, x);
siz[x] += siz[e[i].v];
mx[x] = Max(mx[x], siz[e[i].v]);
}
mx[x] = Max(mx[x], sum - siz[x]);
if (mx[x] < mx[rt]) rt = x;
}
void calcdist(int x, int fa) {
if (fa != rt) b[x] = b[fa];
else b[x] = x;
subt[++ dcnt] = x, scnt[b[x]] ++;
for (int i = head[x]; i; i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v])
dist[e[i].v] = dist[x] + e[i].w,
calcdist(e[i].v, x);
}
void dfz(int x, int fa) {
subt[++ dcnt] = x, b[x] = x, dist[x] = scnt[x] = 0;
for (int i = head[x]; i; i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v]) {
dist[e[i].v] = e[i].w; scnt[e[i].v] = 0;
calcdist(e[i].v, x);
}
sort(subt + 1, subt + dcnt + 1, cmp);
int l = 1, r = dcnt; vis[x] = true;
while (l < r) {
while (dist[subt[l]] + dist[subt[r]] > m)
scnt[b[subt[r]]] --, r --;
res += r - l - scnt[b[subt[l]]];
l ++, scnt[b[subt[l]]] --;
}
dcnt = 0;
for (int i = head[x]; i;i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v]){
sum = siz[e[i].v], mx[rt = 0] = INF;
calcsiz(e[i].v, x); calcsiz(rt, -1);
dfz(rt, x);
}
}
int main() {
nowt = time(0);
scanf("%d", &n);
for (int i = 1; i < n; i ++) {
int u, v, w;scanf("%d%d%d", &u, &v, &w);
add(u, v, w); add(v, u, w);
}
mx[rt = 0] = INF, sum = n;
calcsiz(1, -1); calcsiz(rt, -1);
scanf("%d", &m); dfz(rt, -1);
printf("%d", res);
return 0;
}
#2.3 P2634 [国家集训队]聪聪可可
题目实际上让求长度是 \(3\) 的倍数的有序点对的个数。
看到这题,笔者首先想到的是树形 \(\texttt{DP}\) 而非点分治,当然树形 \(DP\) 是可做的,与点分治转移的方式接近,且时间复杂度似乎更优一些,但树形 \(\texttt{DP}\) 并不是本文所讨论的内容,这里不做说明,来看看本题点分治的做法。
一样的考虑方式,假设根为 \(rt\),那么一共有经过 \(rt\) 与不经过两种情况,不经过的情况可以递归处理。
来看经过 \(rt\) 的情况,能够拼成 \(3\) 的倍数的情况只有以下 \(2\) 种:
- 两边链长度都是 \(3\) 的倍数;
- 一边链长度模 \(3\) 余 \(1\),另一边模 \(3\) 余 \(2\);
我们可以统计每棵子树上与 \(rt\) 的距离 \(3\) 的倍数、长度模 \(3\) 余 \(1\)、模 \(3\) 余 \(2\) 的点的个数 \(b_0,b_1,b_2\),设前面的子树上所有与 \(rt\) 的距离 \(3\) 的倍数、长度模 \(3\) 余 \(1\)、模 \(3\) 余 \(2\) 的点的个数分别为 \(db_0,db_1,db_2\),那么这棵子树上可贡献的数量为
累加入答案,之后将 \(b\) 与 \(db\) 合并。最后递归进入子树进行点分治。
注意到,\((1,2)\) 与 \((2,1)\) 是两种不同的答案,单个点(即路径长度为 \(0\))也是一种答案,故统计出的答案应当乘二再加上总共点的个数。总共有 \(n^2\) 种不同的路径,别忘要分子分母化为互质的两个数。
const int N = 100010;
const int INF = 0x3fffffff;
struct Edge {
int u, v, w;
int nxt;
};
Edge e[N];
int n, cnt = 1, head[N], vis[N], db[5], res;
int siz[N], mx[N], rt, sum, b[5], dist[N], dcnt;
template <typename T>
inline T Max(const T a, const T b) {
return a > b ? a : b;
}
template <typename T>
inline T Min(const T a, const T b) {
return a < b ? a : b;
}
int gcd(int a, int b){
if (!b) return a;
return gcd(b, a % b);
}
inline void add(const int &u, const int &v, const int &w) {
e[cnt].u = u, e[cnt].v = v, e[cnt].w = w;
e[cnt].nxt = head[u], head[u] = cnt ++;
}
void calcsiz(int x, int fa) {
siz[x] = 1, mx[x] = 0;
for (int i = head[x]; i; i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v]) {
calcsiz(e[i].v, x);
siz[x] += siz[e[i].v];
mx[x] = Max(mx[x], siz[e[i].v]);
}
mx[x] = Max(mx[x], sum - siz[x]);
if (mx[x] < mx[rt]) rt = x;
}
void calcdist(int x, int fa) {
b[dist[x] % 3] ++;
for (int i = head[x]; i; i = e[i].nxt)
if (e[i].v != fa && !vis[e[i].v])
dist[e[i].v] = dist[x] + e[i].w,
calcdist(e[i].v, x);
}
void dfz(int x, int fa) {
vis[x] = true, db[0] = db[1] = db[2] = 0, dist[x] = 0;
for (int i = head[x]; i; i = e[i].nxt) {
if (e[i].v == fa || vis[e[i].v]) continue;
b[0] = b[1] = b[2] = 0;
dist[e[i].v] = e[i].w;
calcdist(e[i].v, x);
res += b[0] * db[0] + b[1] * db[2] + b[2] * db[1];
db[0] += b[0], db[1] += b[1], db[2] += b[2];
}
res += db[0];
for (int i = head[x]; i; i = e[i].nxt) {
if (e[i].v == fa || vis[e[i].v]) continue;
sum = siz[e[i].v], mx[rt = 0] = INF;
calcsiz(e[i].v, x); calcsiz(rt, -1);
dfz(rt, x);
}
}
int main() {
scanf("%d", &n);
for (int i = 1; i < n; i ++) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
add(u, v, w); add(v, u, w);
}
sum = n, mx[rt = 0] = INF;
calcsiz(1, -1); calcsiz(rt, -1);
dfz(rt, -1); res = res * 2 + n;
int gd = gcd(res, n * n);
printf("%d/%d", res / gd, n * n / gd);
return 0;
}
#2.4 总结
结合上面 \(3\) 道例题,不难发现,点分治并没有固定的信息维护方式,仅提供了一种方法、思路,是分治思想在树上的运用,具体怎样转移、维护还需要分析所需维护的信息的性质。
参考资料
[1] 树分治 - OI Wiki