2023.7.10 树论专题
补题...已经...无所谓了
T1 [USACO19DEC] Milk Visits G
我们还是考虑怎么求 发现如果我们能求出每个点到根节点的颜色出现情况
然后还是用 \(LCA\) 那个思路做树上前缀和 直接就秒了
进一步的 我们发现 这玩意可以主席树维护
然后就被锐评数据结构魔怔人了
实际还有一个离线的做法
我们还是考虑用一个数组记录每个颜色出现情况
然后我们把每个询问挂到 \(x\) \(y\) \(lca(x, y)\) 上 然后扫到这个点就更新询问
这样 \(dfs\) 的时候正常修改回溯就行
点击查看代码
#include <bits/stdc++.h>
#define mid ((l + r) >> 1)
using namespace std;
const int N = 1e5 + 0721;
const int M = 5e6 + 0721;
int head[N << 1], nxt[N << 1], to[N << 1], cnt;
int T[N], c[N], ans[N];
int n, m;
inline void cmb(int x, int y) {
to[++cnt] = y;
nxt[cnt] = head[x];
head[x] = cnt;
}
struct tree {
int tr[M], ls[M], rs[M];
int tot = 0;
int build(int l, int r) {
int k = ++tot;
if (l == r) return k;
ls[k] = build(l, mid);
rs[k] = build(mid + 1, r);
return k;
}
int modify(int pre, int l, int r, int loc) {
int k = ++tot;
tr[k] = tr[pre], ls[k] = ls[pre], rs[k] = rs[pre];
if (l == r) {
++tr[k];
return k;
}
if (loc <= mid) ls[k] = modify(ls[pre], l, mid, loc);
else rs[k] = modify(rs[pre], mid + 1, r, loc);
return k;
}
int query(int pre, int k, int l, int r, int loc) {
if (l == r) {
return tr[k] - tr[pre];
}
if (loc <= mid) return query(ls[pre], ls[k], l, mid, loc);
else return query(rs[pre], rs[k], mid + 1, r, loc);
}
} seg;
struct tr {
int fa[N][21];
int dep[N];
void init() {
for (int j = 1; j <= 20; ++j) {
for (int i = 1; i <= n; ++i) fa[i][j] = fa[fa[i][j - 1]][j - 1];
}
}
void dfs(int x, int f) {
for (int i = head[x]; i; i = nxt[i]) {
int y = to[i];
if (y == f) continue;
dep[y] = dep[x] + 1;
fa[y][0] = x;
dfs(y, x);
}
}
int find(int x, int y) {
if (dep[x] > dep[y]) swap(x, y);
for (int j = 20; j >= 0; --j) {
if (dep[fa[y][j]] >= dep[x]) y = fa[y][j];
}
if (x == y) return x;
for (int j = 20; j >= 0; --j) {
if (fa[x][j] != fa[y][j]) {
x = fa[x][j];
y = fa[y][j];
}
}
return fa[x][0];
}
} lca;
void dfs(int x, int fa) {
T[x] = seg.modify(T[fa], 1, n, c[x]);
for (int i = head[x]; i; i = nxt[i]) {
int y = to[i];
if (y == fa) continue;
dfs(y, x);
}
}
int find_ans(int x, int y, int col) {
int f = lca.find(x, y);
int ff = lca.fa[f][0];
int ret = 0;
ret += seg.query(T[ff], T[x], 1, n, col);
ret += seg.query(T[f], T[y], 1, n, col);
return ret;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i) scanf("%d", &c[i]);
for (int i = 1; i < n; ++i) {
int x, y;
scanf("%d%d", &x, &y);
cmb(x, y);
cmb(y, x);
}
T[0] = seg.build(1, n);
lca.dep[1] = 1;
lca.dfs(1, 0);
lca.init();
dfs(1, 0);
for (int i = 1; i <= m; ++i) {
int x, y, col;
scanf("%d%d%d", &x, &y, &col);
ans[i] = find_ans(x, y, col);
}
for (int i = 1; i <= m; ++i) printf("%d", ans[i] ? 1 : 0);
return 0;
}
T2 Trees of Tranquillity
有了之前会场一二那题的经验 这题我们就考虑从第一棵树中在一条链的点中选树二中不在一条链上的点
那么首先要求它们在树一的一条链中嘛 所以我们通过 \(dfs\) 加上回溯来维护这个东西
然后判断它们在树二中不在一条链上 实际是个比较套路的做法
首先我们把这个条件转化一下 那么就是一个点肯定不在另一个点的子树内
然后我们 \(dfs\) 回溯的时候再记录一下当前时间戳 就像这样
void dfs(int x) {
s[x] = ++dfs_clock;
for (int v : b[x]) dfs(v);
e[x] = dfs_clock;
}
这样你就会发现 在一棵树中一个点的子树实际上就是 \(dfs\) 序中连续的一段
那么问题就转化为让选出来的点互不相交
不难发现 因为子树间肯定是包含关系 所以在 \(dfs\) 序中对应的区间如果重叠 一定也是包含关系
那么我们就有个贪心的想法 如果我们在树一中 \(dfs\) 到这个点 它在树二中对应的区间已经有东西了
如果是它包含这个区间 那就不动 否则就把那个拿掉把它放进去
如果没有东西 显然直接插进去就行
其实到这这题已经可以写了
但是这题的输入方式 有个性质就是 \(fa_i \le i\) 所以实际上不会出现它包含这个区间的情况
所以如果有区间已经有东西了 我们直接把那个区间拿掉再把它放进去
具体操作可以用线段树维护 平衡树 \(set\) 啥的也可以
点击查看代码
#include <bits/stdc++.h>
#define ls (k << 1)
#define rs (k << 1 | 1)
#define mid ((l + r) >> 1)
using namespace std;
const int N = 3e5 + 0721;
vector<int> a[N];
vector<int> b[N];
int s[N], e[N];
int dfs_clock;
int T, n;
int cnt, ans;
struct tree {
int tr[N << 2], lazy[N << 2];
inline void pushdown(int k) {
lazy[ls] = lazy[rs] = lazy[k];
tr[ls] = tr[rs] = lazy[k];
lazy[k] = -1;
}
inline void pushup(int k) {
tr[k] = max(tr[ls], tr[rs]);
}
void modify(int k, int l, int r, int u, int v, int val) {
if (u <= l && v >= r) {
tr[k] = val;
lazy[k] = val;
return;
}
if (lazy[k] != -1) pushdown(k);
if (u <= mid) modify(ls, l, mid, u, v, val);
if (v > mid) modify(rs, mid + 1, r, u, v, val);
pushup(k);
}
int query(int k, int l, int r, int u, int v) {
if (u <= l && v >= r) {
return tr[k];
}
if (lazy[k] != -1) pushdown(k);
int ret = 0;
if (u <= mid) ret = max(ret, query(ls, l, mid, u, v));
if (v > mid) ret = max(ret, query(rs, mid + 1, r, u, v));
return ret;
}
} seg;
void dfs(int x) {
s[x] = ++dfs_clock;
for (int v : b[x]) dfs(v);
e[x] = dfs_clock;
}
void dfs_ans(int x) {
int maxn = seg.query(1, 1, n, s[x], e[x]);
if (maxn) {
seg.modify(1, 1, n, s[maxn], e[maxn], 0);
--cnt;
}
seg.modify(1, 1, n, s[x], e[x], x);
++cnt;
ans = max(ans, cnt);
for (int v : a[x]) dfs_ans(v);
--cnt;
seg.modify(1, 1, n, s[x], e[x], 0);
if (maxn) {
++cnt;
seg.modify(1, 1, n, s[maxn], e[maxn], maxn);
}
}
int main() {
scanf("%d", &T);
while (T--) {
dfs_clock = 0;
cnt = ans = 0;
scanf("%d", &n);
int x;
for (int i = 2; i <= n; ++i) {
scanf("%d", &x);
a[x].push_back(i);
}
for (int i = 2; i <= n; ++i) {
scanf("%d", &x);
b[x].push_back(i);
}
dfs(1);
dfs_ans(1);
printf("%d\n", ans);
for (int i = 1; i <= n; ++i) {
a[i].clear();
b[i].clear();
}
for (int i = 1; i <= (n << 2); ++i) {
seg.tr[i] = 0;
seg.lazy[i] = -1;
}
}
return 0;
}
T3 [SDOI2011] 消防
考察直径性质的一道题
首先这个枢纽肯定是建在直径上的一段 否则可以画图证明出偏心距一定会不会更小
(说句闲话 很多直径性质就是从中间一个点往外拐那段线肯定没有那个点到直径端点的线段长)
并且我们发现假如说我固定其中一个端点 那么枢纽的另一个端点一定是越远越好
那我们考虑对于我们枚举在直径上的其中一个端点 如何计算此时的偏心距
还是上面那个性质 对于在我选定的这段区间内的某个点 它拐出去肯定是不如它到两个端点的距离远的
我们继续考虑 因为要求离直径最远的点 我们不妨 \(dfs\) 出直径上的每个点在不经过直径上的点的情况下能到达的最远点 显然每个点只会被访问过一次 我们设这玩意为 \(d_i\)
设直径的两个端点为 \(x\) \(y\)
那么以 \(u\) \(v\) 为枢纽的偏心距就为 \(max(max(d_{i = u - v}), dis(x, u), dis(v, y))\)
实际这个区间最大值我们就能做
但是因为上面的性质 我们可以把这个柿子化为 \(max(max(d_{i = x - y}), dis(x, u), dis(v, y))\) 做起来更方便
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 0721;
int pre[N], dis[N];
int head[N], nxt[N << 2], to[N << 2], val[N << 2], cnt;
bool vis[N];
int n, s;
int x1, x2;
int ans;
inline void add_edge(int x, int y, int z) {
to[++cnt] = y;
val[cnt] = z;
nxt[cnt] = head[x];
head[x] = cnt;
}
void dfs(int x, int fa, bool flag) {
for (int i = head[x]; i; i = nxt[i]) {
int y = to[i];
if (y == fa) continue;
if (vis[y]) continue;
dis[y] = dis[x] + val[i];
if (flag) pre[y] = x;
dfs(y, x, flag);
}
}
void find_d() {
dfs(1, 0, 0);
for (int i = 1; i <= n; ++i) if (dis[i] > dis[x1]) x1 = i;
dis[x1] = 0;
dfs(x1, 0, 1);
for (int i = 1; i <= n; ++i) if (dis[i] > dis[x2]) x2 = i;
}
void solve() {
find_d();
for (int i = x2; i; i = pre[i]) {
vis[i] = 1;
dis[i] = 0;
}
for (int i = x2; i; i = pre[i]) dfs(i, 0, 0);
for (int i = 1; i <= n; ++i) ans = max(ans, dis[i]);
printf("%d", ans);
}
int main() {
scanf("%d%d", &n, &s);
for (int i = 1; i < n; ++i) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
add_edge(x, y, z);
add_edge(y, x, z);
}
solve();
return 0;
}
T4 [NOIP2012 提高组] 疫情控制
找切入点
军队的移动是不定向的 那就很乱 我们思考一下有没有什么一定最优的移动方式
进而发现肯定一直往上走是最优的 那么这题就可以继续往下做了
根据这个至少 我们想到可能可以二分答案来做 这样可以进一步限制每个军队移动的距离
然后我们思考怎么来 \(check\) 那肯定是把根节点包围了一圈
然后我们发现 有的军队在这个限制距离内走不到根节点 那就在走到最远的点停下来驻扎
另一些军队可以走到根节点
进而发现 我们可能需要一些军队跨过根节点来实现围一圈这个操作
这时候我们又发现 有的军队可能走到根节点回不来 那就让它驻扎在根节点前一步那个子节点
那么对于剩下的 我们根据它剩余可以移动的距离排序 然后优先把剩余移动距离最小的分配给离根节点近的未被覆盖的节点
T5 [NOIP2016 提高组] 天天爱跑步
首先还是第一步把路径转化为 \(LCA\) 那个形式
然后我们考虑怎么统计答案
首先暴力做肯定是不可能的 又不能倍增跳
(是这样的 当要求路径上路过的点的一些信息时 一般不能用倍增跳 因为倍增维护不了这个)
那我们反过来 看能不能求出来跑者对每个点的影响
对于上行 假如 \(x\) 处有个观察员 那就是 \(d[s_i] + d[x] = w[x]\)
移项之后就是 \(d[s_i] = d[x] + w[x]\)
那么假如说我们让这个问题转化为 把上行的路上都放上一个 \(d[s_i]\) 的物品 然后查 \(x\) 点有多少大小为 \(w[x] + d[x]\) 即可
然后就是一个树上差分 利用 \(dfs\) 序和树状数组结合查数量就行
T6 [省选联考 2021 A/B 卷] 宝石
还是根据 \(LCA\) 拆成上下行的两条链 实际上根据前面一顿虐下来应该想到离线 \(dfs\)
但是根本没法做 我断点都不知道在哪
我们考虑上行的过程 发现可以倍增维护出跳到哪个父亲收集到了哪个宝石
但是转移需要知道这个点收集的宝石的种类 这样数组就炸了
然后给的那个数列每个数都不相等 那么每个点收集的宝石的那个位置就是一定的 那没事了
然后我们还要知道一个数在这个点上出现的最低的地方在哪 这个东西拿主席树就行了
然后是下行那段
因为我们要倒着走嘛 所以我们要确定终点是哪个宝石
那考虑二分那个终点 可做
然后还有一个神仙做法可以压掉一个 \(log\) :
先处理完向上的链之后,就知道向下的每条链要从串的哪里开始匹配了。然后对于每个询问的终点挂结束标记。
对于 \(s_i -> x_i\) 的部分,我们直接处理出这一段最多可以获得多少宝石,并把下一个宝石的种类记
为 \(c_i\),然后把二元组 \((c_i, i)\) 挂到节点 \(y_i\) 上,并在节点 \(t_i\) 上挂一个结束标记 \(i\)。
这个部分可以用树上倍增进行处理。
现在只需要对树进行一遍 dfs,并对每个宝石的种类维护一个容器 \(d_i\) ,支持:
- 进入点 \(k\) 时,对于挂在 \(k\) 上的所有二元组 \((i, x)\),把 \(x\) 插入容器 \(d\) 。
- 令点 \(k\) 上的宝石种类 \(w\) 在宝石序列中的后继为 \(r\) ,则把 \(d_w\) 合并到 \(d_r\) 中。
- 对于挂在 \(k\) 上的所有结束标记 \(x\),查询 \(x\) 所在的容器编号。
- 退出点 \(k\) 时,撤销进入点 \(k\) 时进行的操作(包括插入与合并)。
我们发现用带撤销并查集可以很好的实现这个容器。
T7 [CSP-S2019] 树的重心
首先根据定义 设重心的子树大小为 \(siz_x\)
则重心满足:
-
\(siz_x \le \left\lfloor\dfrac{n}{2}\right\rfloor\)
-
\(n - siz_x \le \left\lfloor\dfrac{n}{2}\right\rfloor\)
解法一:
首先有一个结论:重心一定在根节点所在的重链上
然后我们就可以对每个点维护一个倍增数组 记录往重链跳 \(2^i\) 个儿子到达的节点
那我们考虑 假如说我把 \(x\) 和 \(y\) 之间的边断掉 假设 \(x\) 是儿子 那 \(x\) 显然就可以直接用这个倍增求重心
那 \(y\) 呢?
显然 以 \(x\) 为根的那个子树可以继承原来的信息 但是以 \(y\) 为根的那个子树不能 它要成为新根
所以我们考虑换根 \(DP\) 把每个点作为根节点的倍增数组啥的都维护出来 用这个来查显然就没问题了
那我们考虑把原来的父节点当根对于这个儿子信息的影响 显然有可能这个父节点会成为它的新的重儿子 这样我们就用父节点的倍增数组来更新当前这个儿子的倍增数组
但是这还有一个小小的问题 假如 \(x\) 是以 \(y\) 为根节点的时候的重儿子 那显然噶了之后就不能用 \(x\) 了
所以我们再维护一个次重儿子即可
解法二:
我们考虑每个点成为重心的次数
首先对于一个点 \(x \neq rt\) 如果 \(x\) 是割掉某条边之后的重心 那么这条边一定不在 \(x\) 的子树内
不知道为啥题解包括讲义都没给证明 我觉得不是挺显然的
证明:假设 \(x\) 的子树里有一点 \(y\) 让 \(y\) 和它的父亲那条边断开
那么首先 \(x\) 肯定不能是以 \(y\) 为根节点的子树的根
我们考虑剩下的那棵树
现在 \(x\) 的子树大小为 \(siz_x - siz_y\) 整棵树的大小为 \(n - siz_y\)
假设 \(x\) 为重心 那么根据重心性质有 \(n - siz_x \le (n - siz_y) / 2\) 并且 \(siz_x - siz_y \le (n - siz_y) / 2\)
前一个不等式可解得 \(n - 2 * siz_x + siz_y \le 0\) 后一个可解得 \(n + 2 * siz_x - siz_y \le 0\)
这两个柿子加起来 我们就有 \(2 * n \le 0\) 显然是不可能的
所以我们现在对于每个点 设割掉这条边之后另一个子树大小为 \(S\) 设 \(g_x\) 表示 \(x\) 重儿子的 \(siz\)
显然有
- \(n - S - siz_x \le (n - S) / 2\)
- \(g_x \le (n - S) / 2\)
整理得:
- \(n - 2 * siz_x \le S \le n - 2 * g_x\)
我们要找这样的并且不在 \(x\) 的子树内的边
首先统计所有满足上面那个式子的边
我们先开个 \(BIT\) 并把所有点的 \(siz\) 都插进去
然后对于每个点 \(dfs\) 到它的时候在 \(BIT\) 中把 \(siz_{fa_x} --\) 把 \(n - siz_x ++\) 代表割掉这个点和父亲连的那条边 然后对这个点查询上面那个区间
回溯的时候把此操作还原
这么做为啥是对的呢 因为我们考虑 假如说我断掉不是它到根节点路径上的边 我们指定这条边是某儿子连接父亲的那条边 那么断这条边对应的 \(S\) 实际上就是那个儿子点的子树大小
如果是在根节点路径上的边 那么断掉这条边对应的 \(S\) 就是整个点数减去那个子树的 \(siz\)
然后我们还要减掉在它子树里的边 那么我们考虑这些的 \(S\) 显然也是那个子树的 \(siz\) 所以我们 \(dfs\) 的时候直接把对应的 \(siz_x\) 插入到另一棵 \(BIT\) 中 然后回溯的时候两个答案相减就是它子树内的断边
解法三:
一个非常神奇的解法
首先我们回顾一下重心的以下性质:
- 在根节点所在的重链上
- 如果有两个重心 它们之间一定是父子关系
- 设 \(g_x\) 为 \(x\) 的重儿子 那么以 \(x\) 为根的子树的重心一定是以 \(g_x\) 为根的子树的重心的父亲
然后我们考虑断掉一条边 \((x, y)\) 设 \(y\) 是那个深度较大的点
那么现在就有两个子树 一个 \(y\) 一个 \(n - y\)
对于 \(y\) 那个子树 我们直接用性质三预处理出以每个点为子树时的重心
对于另一棵子树 我们分情况讨论:
- \(y\) 不在带根节点的重链上
我们直接预处理出删去 \(1 \sim n\) 大小的子树后的重心 \(O(1)\) 查询即可
- \(y\) 在带根节点的重链上
这种情况我们还可以细分:
- \(y\) 删了对根节点的重儿子没有影响 即重链的上面那段没变
那么重心还是在这条重链上 只不过位置会上移 那么我们如果一开始就把重心作为根节点 那么删完了之后重心还是根节点
- \(y\) 删了之后根节点的重儿子改变
那么我们需要额外预处理出次重儿子那条重链上删去 \(1 \sim n\) 大小子树的重心即可
那么整个预处理就是 \(O(n)\) 查询是 \(O(1)\) 的
T8 [省选联考 2023] 人员调度
no pain / mild / moderate / severe / very severe / ahhhhhhhhhhh.jpg
首先有个反悔贪心 链状的就是当年省选考场上写出来的那玩意
树状的那就是堆启发式合并
当然拿出来讲了肯定就要讲正解
我们继续考虑再往里面加人的操作怎么做
如果没满 直接放进去就行
不然我们就需要拿出来一个最小的然后把他放进去对吧
那我们想到开一个 \(res\) 数组来记录这个子树还剩多少位置能放
那假如我把他放进去了 显然他到根节点所有的 \(res\) 都要 \(-1\) 如果有 \(res = -1\) 就说明要踢人了
那我找到这条链上最下面的一个 \(res = -1\) 的节点 然后查这里面的最小值
首先第一个操作用树链剖分维护区间最小值最靠右的点即可
对于后面那个操作 我们对 \(dfs\) 序开线段树维护区间最小值即可
再就是删除操作 再套个线段树分治 这玩意有时间再补
总复杂度 \(O(n log^3n)\)