最近爆炸式讲课,不得不好好总结了。。。
点分治(树分治)总结
基础内容
前置知识:树的重心
\qquad 对于一棵树中的一个节点,我们将这一节点以及与其相连的边删除,会将这棵树分为若干个连通块。现在记这若干个连通块中包含点数最多的连通块内包含的点数为 s z e m a x [ i ] sze_{max}[i] szemax[i] ,那么树的重心即为使 s z e m a x [ i ] sze_{max}[i] szemax[i] 最小的点 i i i。
\qquad 现在,我们来思考一下树的重心有什么性质。不难发现,树的重心的 s z e m a x sze_{max} szemax 不会超过整棵树点数的一半。这点我们可以用反证法来思考。
\qquad 那么如何求树的重心呢?我们只需求出 s z e [ x ] sze[x] sze[x] ,即以 x x x 为根的子树的大小,然后对于每个节点,它的 s z e m a x [ x ] = max ( s z e [ y ] ( y ∈ s o n [ x ] ) , n − s z e [ x ] ) sze_{max}[x] = \max(sze[y](y\in son[x]), n - sze[x]) szemax[x]=max(sze[y](y∈son[x]),n−sze[x])。要与 n − s z e [ x ] n -sze[x] n−sze[x] 取 max \max max 是因为把 x x x 删去后, x x x 子树外的店会构成一个连通块。
C o d e : Code: Code:
void dfs(int x, int fa) {
int Maxx = 0;
sze[x] = 1;
for(int i = head[x]; i; i = edge[i].nxt) {
int To = edge[i].to;
if(To == fa) continue;
dfs(To, x);
sze[x] += sze[To];
Maxx = max(Maxx, sze[To]);
}
Maxx = max(Maxx, n - sze[x]);
if(Maxx < maxx) {
f.clear();
maxx = Maxx;
f.push_back(x);
}
else if(Maxx == maxx) f.push_back(x);//f数组内存的即为重心的编号
}
基本思想
\qquad 点分治(或树分治),主要是在树上采用分治算法来解决树上路径问题。一般在一棵有根树上,我们可以把路径分为两类:经过树根的路径和不经过树根的路径,而点分治就是先运算合法的过根路径,再将当前根节点删除,递归分治若干子树,把不过根路径转化为子树内的过根路径。
\qquad 现在,我们想,若我们任意选取根节点来进行点分治算法,那么时间复杂度最坏将退化到 O ( n 2 ) O(n^2) O(n2) ,即链的情况。这是我们不能接受的,因为它跟暴力一个层级……然而,我们思考,如果我们每次以树的重心作为根,那么点分治的时间复杂度是不是就平均成了 O ( n l o g n ) O(nlogn) O(nlogn) 呢?现在考虑证明。我们已经知道,树的重心的 s z e m a x sze_{max} szemax 一定小于等于整棵树的节点数的一半,那么我们每次选取树的重心为根,将根删除后,即使是链,被分开的两个连通块的大小都为 n / 2 n / 2 n/2( n n n 即为整棵树的节点数)。一直递归下去,最多递归 l o g n logn logn 次,就可以将整棵树处理完。所以以树的重心为根是使时间复杂度最小的方案。
\qquad 时间复杂度搞定后,我们来思考:如何才能求出所有符合要求的过根路径呢?不难想到,一条过根路径一定是由一段左子树上的链与一段右子树上的链拼成的,所以我们可以每次遍历 x x x 的所有子树,当前子树产生的贡献由前几个子树计算完记录的信息来更新。
代码框架
\qquad 说了这么多,点分治的思路框架也已经出来了:先求树的重心作为根,再遍历子树求答案,最后递归分治求解子树内的答案。那么,代码框架也就不难写了。
void get_root(int x, int fa) {//求树的重心
sze[x] = 1;
int Maxx = 0;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(To == fa || vis[To]) continue;
get_root(To, x);
sze[x] += sze[To];
Maxx = max(Maxx, sze[To]);
}
Maxx = max(Maxx, now - sze[x]);//now:当前正在求解的树的节点个数
if(Maxx < maxx) {
maxx = Maxx;
root = x;
}
}
void calc(int x, int fa) {//累加上前面子树计算完的结果
}
void change(int x, int fa, int opt) {//当前子树计算完后,将计算结果记录(相当于更新桶数组)
}
void solve(int x) {//点分治
vis[x] = 1;//标记走过
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
//在此赋初值
calc(To, 0);
change(To, 0, 1);
}
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
change(To, 0, 0);//回溯,还原桶数组
}
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
now = sze[To], root = 0, maxx = 1e9;
get_root(To, 0);
get_root(root, 0);//再以root为根做一遍是为了再求一遍sze让后边递归子树时now值是准确的
solve(root);//递归求解
}
}
例题
下面,我们通过几道例题来更深一步理解点分治。
#1 [IOI 2011]Race
\qquad 题面
\qquad 一道很显然的点分治题,想要满足边权和为 k k k,同时还要边数最小,我们便可以开一个 m i n e mine mine 数组, m i n e [ k ] mine[k] mine[k] 表示距离当前根节点距离为 k k k 的边数最小值,然后每次在 c a l c calc calc 函数内统计答案,在 c h a n g e change change 函数内维护 m i n e mine mine 数组即可。
C o d e : Code: Code:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 2e5 + 5;
int n, k;
struct pic {
int lst, to, val;
}edge[maxn << 1];
int head[maxn], tot = 0;
int sze[maxn], maxx, root, now;
int mine[maxn * 5], ans;//mine[i]:到根的距离为i的边数最小值
int dis[maxn], dep[maxn];
bool vis[maxn];
inline int read() {
int x = 0, f = 1;
char ch = getchar();
while(!isdigit(ch)) {
if(ch == '-') f = -1;
ch = getchar();
}
while(isdigit(ch)) {
x = (x << 1) + (x << 3) + (ch ^ 48);
ch = getchar();
}
return x * f;
}
inline void add(int x, int y, int z) {
edge[++ tot].lst = head[x];
edge[tot].to = y;
edge[tot].val = z;
head[x] = tot;
}
void get_root(int x, int fa) {
sze[x] = 1;
int Maxx = 0;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(To == fa || vis[To]) continue;
get_root(To, x);
sze[x] += sze[To];
Maxx = max(Maxx, sze[To]);
}
Maxx = max(Maxx, now - sze[x]);
if(Maxx < maxx) {
maxx = Maxx;
root = x;
}
}
void calc(int x, int fa) {
if(dis[x] > k) return ;
ans = min(ans, dep[x] + mine[k - dis[x]]);//一共距离为k,当前距离为dis[x],边数为dep[x],找前面子树中与当前边权合起来刚好为k(即k-dis[x])的答案
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(To == fa || vis[To]) continue;
dis[To] = dis[x] + Val;
dep[To] = dep[x] + 1;
calc(To, x);
}
}
void change(int x, int fa, int opt) {
if(dis[x] > k) return ;
if(opt) mine[dis[x]] = min(mine[dis[x]], dep[x]);//维护mine
else mine[dis[x]] = n;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(To == fa || vis[To]) continue;
change(To, x, opt);
}
}
void solve(int x) {
mine[0] = 0;
vis[x] = 1;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(vis[To]) continue;
dep[To] = 1;//深度(边数)
dis[To] = Val;
calc(To, 0);
change(To, 0, 1);//更新桶数组
}
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
change(To, 0, 0);//回溯,还原桶数组
}
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
now = sze[To], root = 0, maxx = 1e9;
get_root(To, 0);
get_root(root, 0);
solve(root);
}
}
int main() {
n = read(), k = read();
memset(head, -1, sizeof head);
for(int i = 1, x, y, z; i < n; i ++) {
x = read() + 1, y = read() + 1, z = read();
add(x, y, z), add(y, x, z);
}
for(int i = 1; i <= k; i ++) mine[i] = n;
now = ans = n;
maxx = 1e9, root = 0;
get_root(1, 0);
get_root(root, 0);
solve(root);
if(ans == n) puts("-1");
else printf("%d\n", ans);
return 0;
}
#2 [USACO13OPEN] Yin and Yang G
\qquad 题面
\qquad 首先,题面中的“路径条数”让我们一眼点分治。然而,我们如何去查找一段平衡的路径呢?现在,我们想,如果把“阳边”的边权看做 1 1 1,把“阴边”的边权看做 − 1 -1 −1,那么是不是只需找一段边权和为 0 0 0 的路径就行了呢?找“平衡路径”的问题解决了,那么如何解决休息点的问题呢?不难发现,若 ( x , y ) (x, y) (x,y) 这条路径上有一个休息点 k k k,那么一定满足 d i s ( x − > k ) = d i s ( k − > y ) dis(x->k) = dis(k->y) dis(x−>k)=dis(k−>y) 。所以我们只需记录一个 d i s [ x ] dis[x] dis[x] 表示 x x x 距离当前根节点的路径权值,那么找到一个 d i s [ y ] dis[y] dis[y] 与之前记录的 d i s [ x ] dis[x] dis[x] 相同即可。又因为要统计路径条数,所以我们要开数组另外记录 d i s [ x ] dis[x] dis[x] 出现的次数。不难发现,我们需要开两个数组分别记录子树外的 d i s [ x ] dis[x] dis[x] 条数与子树内的 d i s [ x ] dis[x] dis[x] 条数,前后拼凑才能保证一条过根路径满足条件。然而,我们又双叒叕发现,我们如何判断拼出的这条路径中间有没有休息点呢?这时,我们想:若我们在递归到点 x x x 之前,就已经有长度为 d i s [ x ] dis[x] dis[x] 的路径了,那是不是一定可以拼出一条合法路径呢?我们只需要以上一个长度为 d i s [ x ] dis[x] dis[x] 的点作为休息点即可。但如果没出现过,我们就不确定是否能拼出来。所以我们记录条数的数组还应再多加一维为 0 / 1 0/1 0/1 ,来记录这一距离以前是否出现过,若出现过则累加到 1 1 1 的那一维,否则加到 0 0 0 的那一维。总体框架就是这样,还有更多细节体现在代码里。
C o d e : Code: Code:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5 + 5;
typedef long long LL;
int n;
LL ans = 0;
struct pic {
int lst, to, val;
}edge[maxn << 1];
int head[maxn], tot = 0;
int sze[maxn], root, maxx, now;
int dis[maxn], max_deep;
int t[maxn << 1];//标记这一边权以前是否出现过
LL g[maxn << 1][2], f[maxn << 1][2];//g:子树外的长度为i的路径,有/无休息点的条数 f:子树内
bool vis[maxn];
inline int read() {
int x = 0, f = 1;
char ch = getchar();
while(!isdigit(ch)) {
if(ch == '-') f = -1;
ch = getchar();
}
while(isdigit(ch)) {
x = (x << 1) + (x << 3) + (ch ^ 48);
ch = getchar();
}
return x * f;
}
inline void add(int x, int y, int z) {
edge[++ tot].lst = head[x];
edge[tot].to = y;
edge[tot].val = z;
head[x] = tot;
}
void get_root(int x, int fa) {
sze[x] = 1;
int Maxx = 0;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(To == fa || vis[To]) continue;
get_root(To, x);
sze[x] += sze[To];
Maxx = max(Maxx, sze[To]);
}
Maxx = max(Maxx, now - sze[x]);
if(Maxx < maxx) {
maxx = Maxx;
root = x;
}
}
void dfs(int x, int fa) {
max_deep = max(max_deep, abs(dis[x] - n));//纪录最大边长,便于更新桶数组
if(t[dis[x]]) ++ f[dis[x]][1];//若出现过,则有休息点
else ++ f[dis[x]][0];//否则没有
++ t[dis[x]];//只有端点为子树内的点的路径才能用当前点做休息点,所以t[]标记在进入子树前加上,出子树减去
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(To == fa || vis[To]) continue;
dis[To] = dis[x] + Val;
dfs(To, x);
}
-- t[dis[x]];//出子树减去
}
void solve(int x) {
vis[x] = 1, g[n][0] = 1;//g[n][0]=1是根节点作为休息点的情况
int mx_deep = 0;//减小清零时间复杂度的,只用把用过的清零即可
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(vis[To]) continue;
dis[To] = n + Val;//+n是因为有负边权,让边权整体偏移n
max_deep = 1;
dfs(To, 0);
mx_deep = max(mx_deep, max_deep);
ans += (g[n][0] - 1) * f[n][0];//根节点不能作为端点,所以是(g[n][0]-1)
for(int j = -max_deep; j <= max_deep; j ++) {
ans += (g[n - j][1] * f[n + j][1]) + (g[n - j][0] * f[n + j][1]) + (g[n - j][1] * f[n + j][0]);//至少保证有一个休息点,即有一个维度为1的
}
for(int j = -max_deep; j <= max_deep; j ++) {
g[n - j][1] += f[n - j][1];
g[n - j][0] += f[n - j][0];
f[n - j][1] = f[n - j][0] = 0;//更新桶数组
}
}
for(int i = -mx_deep; i <= mx_deep; i ++) {
g[n - i][0] = g[n - i][1] = 0;
}
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
now = sze[To];
root = 0;
maxx = 1e9;
get_root(To, 0);
get_root(root, 0);
solve(root);
}
}
int main() {
n = read();
memset(head, -1, sizeof head);
for(int i = 1, x, y, z; i < n; i ++) {
x = read(), y = read(), z = read();
if(!z) z = -1;//把“阴边”的边权变成-1
add(x, y, z);
add(y, x, z);
}
maxx = 1e9;
now = n;
get_root(1, 0);
get_root(root, 0);//基本操作
solve(root);
printf("%lld\n", ans);
return 0;
}
#3 [国家集训队] 聪聪可可
\qquad 题面
\qquad 有了上一题的经验,我们很容易能发现本题算是上一题的弱化版,只需统计长度为 3 3 3 的倍数的路径在子树内外出现的条数即可。因为路径的长度并不重要,重要的是这条路径边权和是否为 3 3 3 的倍数,所以计算时让边权、路径边权和整体对 3 3 3 取模也不影响答案。
C o d e : Code: Code:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 2e4 + 5;
int n;
struct pic {
int lst, to, val;
}edge[maxn << 1];
int head[maxn], tot = 0;
int sze[maxn], root, maxx, now;
int f[3], g[3];//子树内、子树外
bool vis[maxn];
int dis[maxn];
int ans1 = 0, ans2 = 0;
inline int read() {
int x = 0, f = 1;
char ch = getchar();
while(!isdigit(ch)) {
if(ch == '-') f = -1;
ch = getchar();
}
while(isdigit(ch)) {
x = (x << 1) + (x << 3) + (ch ^ 48);
ch = getchar();
}
return x * f;
}
inline void add(int x, int y, int z) {
edge[++ tot].lst = head[x];
edge[tot].to = y;
edge[tot].val = z;
head[x] = tot;
}
void get_root(int x, int fa) {
sze[x] = 1;
int Maxx = 0;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(To == fa || vis[To]) continue;
get_root(To, x);
sze[x] += sze[To];
Maxx = max(Maxx, sze[To]);
}
Maxx = max(Maxx, now - sze[x]);
if(Maxx < maxx) {
maxx = Maxx;
root = x;
}
}
void dfs(int x, int fa) {
f[dis[x] % 3] ++;//不用判断以前是否出现过,直接累加答案即可
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(To == fa || vis[To]) continue;
dis[To] = dis[x] + Val;
dfs(To, x);
}
}
void solve(int x) {
vis[x] = 1;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(vis[To]) continue;
dis[To] = Val;
dfs(To, x);
ans1 += g[0] * f[0] + g[1] * f[2] + g[2] * f[1] + f[0];//+f[0]:可能当前节点到根的距离也合法
g[0] += f[0], g[1] += f[1], g[2] += f[2];
f[0] = f[1] = f[2] = 0;
}
g[0] = g[1] = g[2] = 0;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
root = 0, maxx = 1e9, now = sze[To];
get_root(To, 0);
get_root(root, 0);
solve(root);
}
}
int gcd(int x, int y) {
if(x < y) swap(x, y);
while(y) {
int temp = x % y;
x = y;
y = temp;
}
return x;
}
int main() {
n = read();
memset(head, -1, sizeof head);
for(int i = 1, x, y, z; i < n; i ++) {
x = read(), y = read(), z = read();
z %= 3;
add(x, y, z);
add(y, x, z);
}
now = n, root = 0, maxx = 1e9;
get_root(1, 0);
get_root(root, 0);
solve(root);
ans1 <<= 1;
ans1 += n;
ans2 = n * n;
int Gcd = gcd(ans1, ans2);
printf("%d/%d\n", ans1 / Gcd, ans2 / Gcd);//约成最简分数
return 0;
}
#4 [2018/7 D班集训]路径规划
\qquad
依然是一道一眼点分治的题。然而本题与前两题不同,前两题是让我们求合法的路径条数,但是本题是让求最大值。怎么办呢?把记录条数改为记录最大值不就行啦! 但是,我们以前查询是只查询固定值的答案(例如
R
a
c
e
Race
Race 中的
m
i
n
e
mine
mine 数组是固定路径长为
k
k
k 的最小边数),而这道题我们需要查询 区间(或后缀)的答案。什么意思呢?假定我们以当前从根开始的路径的最小值为整条路径的最小值,去找前面子树中的路径拼处整条路径,那么我们要查询的就是值域在 [当前从根开始的路径的最小值,极大值] 中的最大边权和。那么该怎么存储区间最值呢?不难想到线段树。但是本蒟蒻亲测线段树会 T。怎么办呢?观察值域,我们发现我们要查询的是一个后缀最大值,那么就很自然的想到用常熟更小的树状数组来搞。另外本题还有一个小细节:我们只用当前路径作为最小值与前面路径拼了,并没有和后面子树拼,这就会导致少考虑情况。这时候我们仅需将边按照反的顺序再建一边,然后再跑一遍即可。
C o d e : Code: Code:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int maxn = 3e5 + 5;
const int maxv = 1e6 + 5;
int n;
LL INF;
struct pic {
int val, to, lst;
}edge[maxn << 1];
LL c[maxv];
int head[maxn], tot = 0;
int sze[maxn], root, now, maxx;
int x[maxn], y[maxn], z[maxn];
LL dis[maxn], minn[maxv];//minn[x]:最小边权为x时最大边权和
bool vis[maxn];
LL ans;
inline int read() {
int x = 0, f = 1;
char ch = getchar();
while(!isdigit(ch)) {
if(ch == '-') f = -1;
ch = getchar();
}
while(isdigit(ch)) {
x = (x << 1) + (x << 3) + (ch ^ 48);
ch = getchar();
}
return x * f;
}
inline int lowbit(int x) {
return x & (-x);
}
//树状数组前缀操作改后缀:将循环的顺序换一下即可,原理可以感性理解一下
void modify(int x, LL y) {
while(x) {//前缀:x<=INF
c[x] = max(c[x], y);
x -= lowbit(x);
}
}
void Clear(int x) {
while(x) {
c[x] = 0;
x -= lowbit(x);
}
}
LL ask(int x) {
LL cnt = 0;
while(x <= INF) {//前缀:x
cnt = max(cnt, c[x]);
x += lowbit(x);
}
return cnt;
}
inline void add(int x, int y, int z) {
edge[++ tot].lst = head[x];
edge[tot].to = y;
edge[tot].val = z;
head[x] = tot;
}
void get_root(int x, int fa) {
sze[x] = 1;
int Maxx = 0;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(To == fa || vis[To]) continue;
get_root(To, x);
sze[x] += sze[To];
Maxx = max(Maxx, sze[To]);
}
Maxx = max(Maxx, now - sze[x]);
if(Maxx < maxx) {
maxx = Maxx;
root = x;
}
}
void calc(int x, int fa, LL Minn) {
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(To == fa || vis[To]) continue;
dis[To] = dis[x] + Val;
LL MINN = min(Minn, 1LL * Val);//本条路径上的最小值
ans = max(ans, MINN * (ask(MINN) + dis[To]));//更新答案
calc(To, x, MINN);
}
}
void change(int x, int fa, int opt, LL Minn) {
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(To == fa || vis[To]) continue;
LL MINN = min(Minn, 1LL * Val);
if(opt) {
minn[MINN] = max(minn[MINN], dis[To]);
modify(MINN, minn[MINN]);//更新桶数组
}
else {
minn[MINN] = 0;
Clear(MINN);
}
change(To, x, opt, MINN);
}
}
void solve(int x) {
vis[x] = 1;
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to, Val = edge[i].val;
if(vis[To]) continue;
dis[To] = Val;
ans = max(ans, 1LL * Val * (ask(Val) + dis[To]));//可以仅由一条边构成
calc(To, x, Val);
minn[Val] = max(minn[Val], dis[To]);
modify(Val, minn[Val]);
change(To, x, 1, Val);
}
change(x, 0, 0, 1e9);
for(int i = head[x]; i != -1; i = edge[i].lst) {
int To = edge[i].to;
if(vis[To]) continue;
now = sze[To], root = 0, maxx = 1e9;
get_root(To, 0);
get_root(root, 0);
solve(root);
}
}
int main() {
n = read();
memset(head, -1, sizeof head);
for(int i = 1; i < n; i ++) {
x[i] = read(), y[i] = read(), z[i] = read();
add(x[i], y[i], z[i]);
add(y[i], x[i], z[i]);
INF = max(INF, 1LL * z[i]);
}
INF ++;
now = n, root = 0, maxx = 1e9;
get_root(1, 0);
get_root(root, 0);
solve(root);
memset(head, -1, sizeof head);
memset(vis, 0, sizeof vis);
memset(c, 0, sizeof c);
tot = 0;
for(int i = n - 1; i >= 1; i --) {//反顺序建边
add(x[i], y[i], z[i]);
add(y[i], x[i], z[i]);
}
memset(minn, 0, sizeof minn);
now = n, root = 0, maxx = 1e9;
get_root(1, 0);
get_root(root, 0);
solve(root);
printf("%lld\n", ans);
return 0;
}