2026.08 训练日记(下)
8.17
P3369
仍然是这道平衡树板子题,但今天使用 Treap(即树堆,Treap = Tree + Heap)。
Treap 是一种同时满足二叉搜索树和堆的性质的数据结构。其每个节点有两个与树的结构有关的关键字,其中的一个关键字是数值 \(\texttt{val}\),用来满足二叉搜索树性质;而第二个关键字则是插入节点时为节点赋予的随机数优先级,用于满足堆性质。
首先提一嘴这个随机数,我们首先要引入 \(\texttt{<ramdom>}\) 与 \(\texttt{<chrono>}\) 头文件,然后使用 \(\texttt{std::mt19937}\) 做随机数,具体初始化代码如下:
std::mt19937 rng(std::chrono::steady_clock::now().time_since_epoch().count());
接下来每次调用 \(\texttt{rng()}\) 都可以生成一个值域在 \([0, 2^{32} -1]\) 的随机数,故特别注意这个要用 \(\texttt{unsigned int}\) 来存。
有了昨天 AVL 树的经验之后,很容易就能得出 Treap 节点结构体应该长这样:
struct Node {
int val;
unsigned int priority; // 一定要记住 mt19937 生成的是 unsigned int!
int cnt;
int size;
Node* ls;
Node* rs;
Node(int v, unsigned int p) : val(v), priority(p), cnt(1), size(1), ls(nullptr), rs(nullptr) {}
};
另需一个节点指针 \(\texttt{root}\)、随机数生成器 \(\texttt{rng}\) 作为 Treap 类的私有成员,当然还有 \(\texttt{getSize}\) 函数和 \(\texttt{pushup}\) 函数:
Node* root;
std::mt19937 rng;
int getSize(Node* x) {
return x ? x->size : 0;
}
void pushup(Node* x) {
if (!x) {
return;
}
x->size = x->cnt + getSize(x->ls) + getSize(x->rs);
}
接着就是右旋和左旋的函数了。由于在 AVL 树中已经讲过,这里不再赘述。
Node* zig(Node* x) {
Node* tmp = x->ls;
x->ls = tmp->rs;
tmp->rs = x;
pushup(x);
pushup(tmp);
return tmp;
}
Node* zag(Node* x) {
Node* tmp = x->rs;
x->rs = tmp->ls;
tmp->ls = x;
pushup(x);
pushup(tmp);
return tmp;
}
求指定元素排名、求指定排名元素、求前驱、求后继的函数也与 AVL 树高度相似:
int getRank(Node* x, int val) {
if (!x) {
return 1;
}
if (val == x->val) {
return getSize(x->ls) + 1;
} else if (val < x->val) {
return getRank(x->ls, val);
} else {
return getSize(x->ls) + x->cnt + getRank(x->rs, val);
}
}
int getByRank(Node* x, int rk) {
if (!x) {
return -INF;
}
int lsize = getSize(x->ls);
if (rk <= lsize) {
return getByRank(x->ls, rk);
} else if (rk <= lsize + x->cnt) {
return x->val;
} else {
return getByRank(x->rs, rk - lsize - x->cnt);
}
}
int getPredecessor(Node* x, int val) {
if (!x) {
return -INF;
}
if (val > x->val) {
int pre = getPredecessor(x->rs, val);
return (pre == -INF) ? x->val : pre;
} else {
return getPredecessor(x->ls, val);
}
}
int getSuccessor(Node* x, int val) {
if (!x) {
return INF;
}
if (val < x->val) {
int suc = getSuccessor(x->ls, val);
return (suc == INF) ? x->val : suc;
} else {
return getSuccessor(x->rs, val);
}
}
我们重点研究插入和删除操作。
首先是插入操作。按二叉搜索树的性质把赋了随机优先级的节点插进来之后,Treap 的堆性质有可能会被破坏。
解决办法不难,如果左子节点优先级高于父节点,那就把父节点右旋,左子节点成为新父节点;右子节点同理。
实现:
Node* insert(Node* x, int val) {
if (!x) {
return new Node(val, rng());
}
if (val == x->val) {
x->cnt++;
} else if (val < x->val) {
x->ls = insert(x->ls, val);
if (x->ls->priority > x->priority) {
x = zig(x);
}
} else {
x->rs = insert(x->rs, val);
if (x->rs->priority > x->priority) {
x = zag(x);
}
}
pushup(x);
return x;
}
然后是删除操作。对于要删除的节点,分四种情况讨论(前三种与 AVL 树相同)。
- 如果节点 \(\texttt{cnt}\) 大于 \(1\),那么直接给 \(\texttt{cnt}\) 和 \(\texttt{size}\) 减去 \(1\) 就好了;
- 如果节点是叶节点,直接删掉就行;
- 如果只有一个子节点,那么把这个子节点接到删除的节点原本所在的位置即可;
- 但如果有两个子节点呢?这时候我们考虑给要删除的节点不断旋转,下沉到叶节点之后删除。
如何旋转可以不破坏堆性质呢(忽略要删的这个节点)?考虑到一次旋转会使两个子节点中的一个上升为父节点,那么我们只需保证上升的这个子节点优先级高于另一个子节点就好了。
所以我们每次旋转选择两个子节点中优先级更高的一个让其上升。
实现如下:
Node* erase(Node* x, int val) {
if (!x) {
return nullptr;
}
if (val == x->val) {
if (x->cnt > 1) {
x->cnt--;
x->size--;
return x;
} else {
if (!x->ls && !x->rs) {
delete x;
return nullptr;
} else if (!x->ls) {
Node* tmp = x->rs;
delete x;
return tmp;
} else if (!x->rs) {
Node* tmp = x->ls;
delete x;
return tmp;
} else {
if (x->ls->priority > x->rs->priority) {
x = zig(x);
x->rs = erase(x->rs, val);
} else {
x = zag(x);
x->ls = erase(x->ls, val);
}
}
}
} else if (val < x->val) {
x->ls = erase(x->ls, val);
} else {
x->rs = erase(x->rs, val);
}
pushup(x);
return x;
}
这样我们的 Treap 功能就实现完了。对外暴露构造函数和六个操作接口就行。完整 Treap 类如下(码量较小于 AVL 树):
constexpr int INF = 0x7fffffff;
class Treap {
private:
struct Node {
int val;
unsigned int priority;
int cnt;
int size;
Node* ls;
Node* rs;
Node(int v, unsigned int p) : val(v), priority(p), cnt(1), size(1), ls(nullptr), rs(nullptr) {}
};
Node* root;
std::mt19937 rng;
int getSize(Node* x) {
return x ? x->size : 0;
}
void pushup(Node* x) {
if (!x) {
return;
}
x->size = x->cnt + getSize(x->ls) + getSize(x->rs);
}
Node* zig(Node* x) {
Node* tmp = x->ls;
x->ls = tmp->rs;
tmp->rs = x;
pushup(x);
pushup(tmp);
return tmp;
}
Node* zag(Node* x) {
Node* tmp = x->rs;
x->rs = tmp->ls;
tmp->ls = x;
pushup(x);
pushup(tmp);
return tmp;
}
Node* insert(Node* x, int val) {
if (!x) {
return new Node(val, rng());
}
if (val == x->val) {
x->cnt++;
} else if (val < x->val) {
x->ls = insert(x->ls, val);
if (x->ls->priority > x->priority) {
x = zig(x);
}
} else {
x->rs = insert(x->rs, val);
if (x->rs->priority > x->priority) {
x = zag(x);
}
}
pushup(x);
return x;
}
Node* erase(Node* x, int val) {
if (!x) {
return nullptr;
}
if (val == x->val) {
if (x->cnt > 1) {
x->cnt--;
x->size--;
return x;
} else {
if (!x->ls && !x->rs) {
delete x;
return nullptr;
} else if (!x->ls) {
Node* tmp = x->rs;
delete x;
return tmp;
} else if (!x->rs) {
Node* tmp = x->ls;
delete x;
return tmp;
} else {
if (x->ls->priority > x->rs->priority) {
x = zig(x);
x->rs = erase(x->rs, val);
} else {
x = zag(x);
x->ls = erase(x->ls, val);
}
}
}
} else if (val < x->val) {
x->ls = erase(x->ls, val);
} else {
x->rs = erase(x->rs, val);
}
pushup(x);
return x;
}
int getRank(Node* x, int val) {
if (!x) {
return 1;
}
if (val == x->val) {
return getSize(x->ls) + 1;
} else if (val < x->val) {
return getRank(x->ls, val);
} else {
return getSize(x->ls) + x->cnt + getRank(x->rs, val);
}
}
int getByRank(Node* x, int rk) {
if (!x) {
return -INF;
}
int lsize = getSize(x->ls);
if (rk <= lsize) {
return getByRank(x->ls, rk);
} else if (rk <= lsize + x->cnt) {
return x->val;
} else {
return getByRank(x->rs, rk - lsize - x->cnt);
}
}
int getPredecessor(Node* x, int val) {
if (!x) {
return -INF;
}
if (val > x->val) {
int pre = getPredecessor(x->rs, val);
return (pre == -INF) ? x->val : pre;
} else {
return getPredecessor(x->ls, val);
}
}
int getSuccessor(Node* x, int val) {
if (!x) {
return INF;
}
if (val < x->val) {
int suc = getSuccessor(x->ls, val);
return (suc == INF) ? x->val : suc;
} else {
return getSuccessor(x->rs, val);
}
}
public:
Treap() : root(nullptr), rng(std::chrono::steady_clock::now().time_since_epoch().count()) {}
void insert(int val) {
root = insert(root, val);
}
void erase(int val) {
root = erase(root, val);
}
int getRank(int val) {
return getRank(root, val);
}
int getByRank(int val) {
return getByRank(root, val);
}
int getPredecessor(int val) {
return getPredecessor(root, val);
}
int getSuccessor(int val) {
return getSuccessor(root, val);
}
};
P6136
用上一题的 Treap,稍微改改主函数小细节就够了。
时限 \(3\) 秒,跑最久的一个测试点只要 \(1\) 秒多一点。
8.18
P2912
LCA 板子。
带权路径额外开个 ST 表存一下就好了,没什么难度的。
P5002
推个式子。
设节点 \(x\) 的子树数量为 \(t_x\),每个子树大小为 \(a_{x,i}\)(\(i \in [1, t_x]\))。
考虑两个节点的 LCA 如何才能成为节点 \(x\),显然只需要满足下面三个条件之一即可:
- 这两个节点分别在 \(x\) 的不同子树中;
- 这两个节点其中一个是 \(x\),另一个是 \(x\) 的后代;
- 这两个节点全都是 \(x\)。
根据加法原理、乘法原理可得,LCA 为节点 \(x\) 的有序点对数量为(默认 \(i, j\in[1, t_x] \, \land \, i \neq j\),并且 \((i,j)\) 有序):
注意到对于任意序列 \(A\),都有
故可以将 \(S_x\) 的式子进行以下推导:
容易发现 \(\left( \sum a_{x, i} + 1 \right)\) 本质上就是以 \(\boldsymbol x\) 为根节点的子树的大小,故我们只需一遍 DFS 计算子树大小、子树大小平方和两个数组就可以对于每次询问 \(O(1)\) 求答案。
总时间复杂度 \(O(N + M)\),跑得飞快。
P2971
容易发现每一组中距离最远的两个节点之一必然是整组中深度最大的节点。
枚举计算即可,\(O(n\log n)\) 过。
P3128
树上差分(点差分)板子。
点差分的通式,对节点 \(u\) 与 \(v\) 之间路径上的所有节点(包括路径两端)点权 \(+x\):
其中 \(d[i]\) 表示节点 \(i\) 的权值差分数组,\(\text{LCA}(u,v)\) 即节点 \(u\) 和 \(v\) 的 LCA,\(f[i][j]\) 为 ST 表,表示节点 \(i\) 的第 \(2^j\) 级祖先。
P4281
要用到 LCA 的“三节点”性质:设 \(u,v,w\) 为树上任意三个互异节点,则 \(\text{LCA}(u,v),\text{LCA}(v,w),\text{LCA}(u,w)\) 中必有至少两个相等。进一步地,这三者中深度最大的节点一定是整棵树中到 \(u,v,w\) 的距离和最小的节点(即 Steiner 点)。
P3938
找规律题。
容易得出,对于一个节点 \(x\),设其父节点为 \(y\),则:
其中 \(F_i\) 为斐波那契数。
照这个规律向上暴力跳 LCA 即可,\(10^{12}\) 的量级最多也就够上 \(F_{60}\) 左右。
当然上面这个结论也不是不能证,只不过要涉及 Fibonacci 进制、Zeckendorf 表示法等等高级的玩意了。
B4339
发现每一次操作最理想的情况就是把两个节点的距离缩短 \(1\)。
考虑 \(\forall e \in E(B) \backslash E(A)\),设 \(e = (u, v)\),则得到这条边的最少操作次数当然是 \(\text{disA}(u, v) - 1\)。
对于 \(A\) 中的上述路径,标记路径中除两端点外的所有节点,最后计数标记的节点数即可。
容易想到使用点差分,最后算出点权时找 \(>1\) 的数量即可。不需要考虑一个节点被标记了多少次。
但是,怎样对去掉两端点的路径进行差分操作呢?
设路径两端点为 \(u, v\),考虑以下三种情况。
- \(u = \text{LCA}(u, v)\)。此时为 \(v\) 的父节点标记 \(+1\),\(u\) 标记 \(-1\)。
- \(v = \text{LCA}(u, v)\)。与上面一种情况同理,为 \(u\) 的父节点标记 \(+1\),\(v\) 标记 \(-1\)。
- \(u, v\) 均不等于 \(\text{LCA}(u, v)\)。此时给 \(u, v\) 的父节点都标记 \(+1\),\(\text{LCA}(u, v)\) 及其父节点都标记 \(-1\) 即可。
考虑到 \((u, v)\) 一定是 \(B\) 中的一条边,所以不存在 \(u = v = \text{LCA}(u, v)\) 的情况。
8.19
P2996、P1922、P8744
简单的树形 DP。
P2458
树形 DP。
注意,对于每个节点 \(u\),应当设计三种状态:
- \(f_{u, 0}\) 表示 \(u\) 不放,但已被父节点所放的覆盖;
- \(f_{u, 1}\) 表示 \(u\) 不放,但已被某个子节点所放的覆盖;
- \(f_{u, 2}\) 表示 \(u\) 放。
设节点 \(u\) 的子节点集为 \(C(u)\)。
我们先考虑 \(f_{u, 2}\) 的转移。显然,此时 \(\forall v \in C(u)\) 可以取任意状态。转移方程如下:
接下来转移 \(f_{u, 0}\)。既然 \(u\) 已被其父节点覆盖,那么 \(\forall v \in C(u)\) 都不可能被其父节点(即 \(u\))覆盖,故只能取 \(1/2\) 两种状态。转移方程:
最后是最难想的 \(f_{u, 1}\)。发现此时 \(\forall v \in C(u)\) 都只能取 \(1/2\) 两种状态,并且必须 \(\exists v_0 \in C(u)\) 取状态 \(2\),否则不能满足 \(u\) 的状态。
考虑一点贪心思想,我们先让 \(u\) 的所有子节点都取 \(1/2\) 中花费最小的状态,再统计要将某个子节点从状态 \(1\) 改为状态 \(2\) 额外花费的最小值。
如果这个最小值为正,则说明 \(u\) 的所有子节点都没有取 \(2\),答案加上这个最小值即可。
否则,说明至少存在一个取了 \(2\) 的子节点。不需要额外操作。
把上述两个小情况综合成一条转移方程就是下面这样的:
根据上述三条方程遍历转移即可。
P3478
换根 DP 板子。
考虑如下图所示树的换根操作:

将树的根节点从红色节点换成蓝色节点(原根和新根一定是某条边的两端点)之后,原树中以蓝色节点为根的子树中所有节点的深度 \(-1\),剩余部分所有节点的深度 \(+1\)。
钦定初始根节点为 \(1\),对整棵树跑 DFS,在每次进入一个节点时统计将树根换为该节点时的深度和。
转移方程(设当前节点为 \(u\),父节点为 \(x\),且 \(f_i\) 表示以节点 \(i\) 为根时的深度和,\(s_i\) 表示原树中以节点 \(i\) 为根的子树大小):
至于 \(s_i\) 和初始 \(f_1\) 的计算就更简单了,只需要一次 DFS:
void get(int u, int fa, int depth) {
f[1] += depth;
for (int i = head[u]; i != -1; i = edge[i].suc) {
int v = edge[i].to;
if (v == fa) {
continue;
}
get(v, u, depth + 1);
size[u] += size[v];
}
}
然后下面这个是计算其它节点 \(f\) 的 DFS:
void calc(int u, int fa) {
if (u != 1) {
f[u] = f[fa] + n - 2 * size[u]; // 注意,当 u 为根节点 1 时不能转移,错误答案会覆盖已经算好的 f[1]
}
for (int i = head[u]; i != -1; i = edge[i].suc) {
int v = edge[i].to;
if (v == fa) {
continue;
}
calc(v, u);
}
}
8.20
P16963
删除的子串非空!删除的子串非空!删除的子串非空!
P16961
正难则反!正难则反!正难则反!
不开 \(\texttt{long long}\) 见祖宗!不开 \(\texttt{long long}\) 见祖宗!不开 \(\texttt{long long}\) 见祖宗!
将题意转化为求由初始态 \(T'\) 通过对以满足 \(c_u' = 1\) 的任意节点 \(u\) 为根的子树 \(T_u'\) 进行反转操作可得的到达态 \(T\) 的数量。
分类讨论:
- 当 \(c_u' = 0\) 时,无法对节点 \(u\) 操作,故统计 \(u\) 所有子节点的答案之积即可;
- 当 \(c_u' = 1\) 时,可以选择是否对节点 \(u\) 操作,注意到此时若对节点 \(u\) 操作,则子树 \(T_u'\) 上除了节点 \(u\) 之外的任意节点的 \(c\) 都可以为 \(0/1\)。若不操作则同上,将两种选择的答案相加即可。
综上,设 \(f_i\) 表示答案状态(初始值为 \(1\)),则状态转移方程为:
其中,\([\;]\) 为 Iverson 括号,\(C(u)\) 为节点 \(u\) 的子节点集,\(|T_u'|\) 为树 \(T'\) 中以 \(u\) 为根节点的子树的节点数。

浙公网安备 33010602011771号