瑞瑞的基因编译
【题目来源】
瑞学堂:瑞瑞的基因编译
【题目描述】
瑞瑞在虚拟实验室中进行一项高级基因编译实验。基因序列可以表示为一个长度为 \(n\) 的字符串 \(S\),仅包含字母 A、T、C、G。
实验室的设备支持三种操作来修改基因序列:
- 单点突变:将下标 \(p\) 位置的字符修改为给定的字符 \(c\)。
- 片段逆转:选择一段区间 \([l,r]\),将该区间内的基因片段进行反向转译。具体来说,对于该区间内的每一个位置 \(i(l≤i≤r)\),将其字符替换为该区间内从右往左数对应位置的字符。这个操作等同于将子串 \(S[l…r]\) 原地反转。
- 变异检测:给定一个区间 \([l,r]\),询问当前基因序列在该区间内的片段 \(S[l…r]\) 是否是回文序列。回文序列是指正着读和反着读完全相同的序列,例如
ATTA是回文,而ATCG不是。
瑞瑞需要按顺序执行 \(q\) 个操作。请你帮助他模拟这个过程,并对于每个类型 \(3\) 的操作(变异检测),输出 YES 或 NO。
【输入】
第一行包含两个整数 \(n\) 和 \(q\),表示基因序列的长度和操作次数。
第二行包含一个长度为 \(n\) 的字符串 \(S\),仅由大写字母 A、T、C、G 组成,下标从 \(1\) 开始。
接下来 \(q\) 行,每行描述一个操作,格式如下:
- 对于单点突变操作:
1 p c,其中 \(p\) 为整数,\(c\) 为字符。 - 对于片段逆转操作:
2 l r,其中 \(l,r\) 为整数。 - 对于变异检测操作:
3 l r,其中 \(l,r\) 为整数。
【输出】
对于每个类型 \(3\) 的操作,输出一行。如果询问的区间 \([l,r]\) 是回文序列,输出 YES,否则输出 NO。
题目保证至少存在一个类型 \(3\) 的操作。
【输入样例】
1 1
A
3 1 1
【输出样例】
YES
【核心思想】
-
问题分析:给定长度为 \(n\) 的字符串 \(S\)(仅含
A/T/C/G),支持三种操作:单点修改、区间反转、区间回文查询。需要在线处理 \(q\) 个操作。这是一个序列维护型 Splay 树 + 双哈希问题,关键在于用 Splay 树维护序列顺序,支持 \(O(\log n)\) 的区间提取、修改、翻转,并用正向/反向哈希 \(O(1)\) 判断回文。 -
算法选择:
- Splay 树(序列维护):通过中序遍历维护序列顺序,支持通过
kth查找第 \(k\) 个元素,通过splay旋转提取任意区间 - 双哈希(正向 + 反向):每个节点维护子树的正向哈希 \(h_1\)(从左到右)和反向哈希 \(h_2\)(从右到左),区间回文判断等价于 \(h_1 = h_2\)
- 懒标记翻转:区间反转操作通过
pushrev交换左右子树和双向哈希,延迟下传保证 \(O(\log n)\)
- Splay 树(序列维护):通过中序遍历维护序列顺序,支持通过
-
关键步骤:
- 建树:递归建立平衡二叉树,中序遍历即为原序列,首尾加入哨兵节点方便区间操作
- 区间提取模板:对于区间 \([l, r]\):
splay(kth(l), 0)将第 \(l-1\) 个节点(左哨兵)旋到根splay(kth(r+2), root)将第 \(r+1\) 个节点(右哨兵)旋到根的右儿子- 目标区间即为根右儿子的左子树
- 单点修改:提取位置 \(pos\) 到根,直接修改
val并pushup - 区间翻转:提取区间后,对目标子树根打
pushrev懒标记 - 回文查询:提取区间后,比较目标子树的 \(h_1\) 和 \(h_2\)
- 哈希维护:
pushup时按 \(h_1 = left \cdot base^{right+1} + val \cdot base^{right} + right\) 计算
-
时间/空间复杂度:
- 时间复杂度:\(O((n+q) \log n)\),每次操作均摊 \(O(\log n)\)
- 空间复杂度:\(O(n)\),Splay 树节点数组
-
序列 Splay + 双哈希的核心思想:
- 中序遍历即序列:Splay 树的中序遍历结果对应原序列,通过
kth和splay实现序列上的任意区间操作 - 哨兵简化边界:在序列首尾加入哨兵节点,使得任意区间 \([l, r]\) 都可统一表示为"左哨兵的右子树中、右哨兵的左子树"
- 双向哈希判回文:正向哈希与反向哈希相等是回文的充要条件(无冲突时),将 \(O(n)\) 的逐字符比较降为 \(O(1)\) 的哈希比较
- 懒标记维护反转:翻转懒标记延迟下传,交换左右子树时同步交换双向哈希,保证反转后哈希值正确
- 适用于"序列上的区间修改、翻转、查询"问题,核心在于将序列操作映射为平衡树上的结构操作
- 中序遍历即序列:Splay 树的中序遍历结果对应原序列,通过
【算法标签】
平衡树
【代码详解】
#include <iostream>
#include <cstring>
#include <cstdlib>
#include <ctime>
using namespace std;
const int MAXN = 300005; // 数组最大容量,满足 n + 哨兵节点 的需求
int n, q; // n: 基因序列长度,q: 操作次数
char str[MAXN]; // 存储初始基因序列的字符数组,下标从1开始
// Splay 树节点结构体(序列维护型)
// 每个节点对应序列中的一个字符,中序遍历即为原序列
struct Node
{
int s[2]; // 左右儿子,s[0]为左儿子,s[1]为右儿子
int p; // 父亲节点编号
int sz; // 子树大小(该子树包含的节点个数)
int rev; // 翻转懒标记(0表示未翻转,1表示需要翻转)
char val; // 节点权值(字符 A/T/C/G 或哨兵字符)
unsigned long long h1, h2; // h1: 子树正向哈希值(从左到右),h2: 子树反向哈希值(从右到左)
// 初始化节点:设置父节点和字符值,其余属性设为默认值
void init(int p1, char v1) // p1为父节点编号,v1为节点字符值
{
p = p1, val = v1;
sz = 1; // 新节点子树大小为1(只有自己)
rev = 0; // 初始无翻转标记
h1 = h2 = v1 - 'A' + 1; // 哈希值初始化为字符对应的数字(A=1,B=2,...,Z=26)
s[0] = s[1] = 0; // 左右儿子初始为空
}
} tr[MAXN]; // Splay树节点数组,静态分配
int root; // Splay树的根节点编号
int idx; // 节点编号计数器,用于分配新节点
unsigned long long base = 131; // 哈希底数,用于计算字符串哈希
unsigned long long pow_base[MAXN]; // 底数的幂次数组:pow_base[i] = base^i,用于快速计算哈希
// 向上更新节点信息:根据左右子树更新当前节点的 sz、h1、h2
// 核心公式:子树哈希 = 左子树哈希 * base^(右子树大小+1) + 当前字符 * base^(右子树大小) + 右子树哈希
void pushup(int x)
{
// 更新子树大小:左子树大小 + 右子树大小 + 当前节点
tr[x].sz = tr[tr[x].s[0]].sz + tr[tr[x].s[1]].sz + 1;
int l = tr[x].s[0], r = tr[x].s[1]; // l为左儿子编号,r为右儿子编号
unsigned long long len_l = tr[l].sz, len_r = tr[r].sz; // 左右子树的大小
// 计算正向哈希 h1:顺序为 左子树 + 当前节点 + 右子树
// 左子树哈希需要左移 (右子树大小 + 1) 位,当前节点左移 (右子树大小) 位
tr[x].h1 = tr[l].h1 * pow_base[len_r + 1] + (tr[x].val - 'A' + 1) * pow_base[len_r] + tr[r].h1;
// 计算反向哈希 h2:顺序为 右子树 + 当前节点 + 左子树(即原序列的反转)
// 右子树哈希需要左移 (左子树大小 + 1) 位,当前节点左移 (左子树大小) 位
tr[x].h2 = tr[r].h2 * pow_base[len_l + 1] + (tr[x].val - 'A' + 1) * pow_base[len_l] + tr[l].h2;
}
// 对节点 x 执行翻转操作(区间反转)
// 效果:交换左右子树,交换正向和反向哈希,打上翻转懒标记
void pushrev(int x)
{
if (!x) return; // 空节点直接返回
swap(tr[x].s[0], tr[x].s[1]); // 交换左右儿子
swap(tr[x].h1, tr[x].h2); // 交换正向和反向哈希
tr[x].rev ^= 1; // 翻转懒标记异或1(0变1,1变0)
}
// 下传翻转懒标记到左右子树
// 在访问节点的子树前必须先调用,确保子树信息正确
void pushdown(int x)
{
if (tr[x].rev) // 如果当前节点有翻转标记
{
pushrev(tr[x].s[0]); // 对左儿子执行翻转
pushrev(tr[x].s[1]); // 对右儿子执行翻转
tr[x].rev = 0; // 清除当前节点的翻转标记
}
}
// 旋转操作:将节点 x 向上旋转一层,保持中序遍历不变
// 这是 Splay 树的核心操作,用于调整树的结构
void rotate(int x)
{
int y = tr[x].p, z = tr[y].p; // y为x的父节点,z为y的父节点
int k = tr[y].s[1] == x; // 判断x是y的左儿子(0)还是右儿子(1)
// 第一步:将x的与y相反方向的子树接到y上
tr[y].s[k] = tr[x].s[k ^ 1]; // y的k方向指向x的(k^1)方向子树
tr[tr[x].s[k ^ 1]].p = y; // 更新该子树的父节点为y
// 第二步:将y接到x的(k^1)方向
tr[x].s[k ^ 1] = y;
tr[y].p = x;
// 第三步:将x接到z的原来y的位置
tr[z].s[tr[z].s[1] == y] = x; // z的原来指向y的方向现在指向x
tr[x].p = z;
// 先更新y的信息(y现在在x下方),再更新x的信息
pushup(y), pushup(x);
}
// Splay操作:将节点 x 旋转到 k 的子节点位置(k=0表示旋转到根)
// 这是 Splay 树的核心操作,通过一系列旋转将x调整到指定位置
void splay(int x, int k)
{
// 不断旋转,直到x的父节点为k
while (tr[x].p != k)
{
int y = tr[x].p, z = tr[y].p;
if (z != k) // 如果y不是根(即z存在)
{
// 折转底,直转中:判断x,y,z是否在一条直线上
// 如果x和y相对于各自父节点的方向不同(一左一右),则先转x(折线情况)
// 如果方向相同(都在左边或都在右边),则先转y(直线情况)
(tr[y].s[0] == x) ^ (tr[z].s[0] == y) ? rotate(x) : rotate(y);
}
rotate(x); // 最后都将x向上旋转一层
}
if (k == 0) // 如果k是0,说明x被旋转到了根
{
root = x; // 更新根节点为x
}
}
// 创建一个新节点,返回节点编号
int get_node(char c)
{
int u = ++idx; // 分配新节点编号
tr[u].init(0, c); // 初始化节点,父节点暂时为0
return u; // 返回新节点编号
}
// 递归建树:对区间 [l, r] 建立平衡的二叉搜索树,p 为父节点编号
// 采用中序遍历建树,保证中序遍历结果与原序列一致
int build(int l, int r, int p)
{
if (l > r) return 0; // 区间为空,返回空节点
int mid = (l + r) >> 1; // 取中点作为当前子树的根
int u = get_node(str[mid]); // 创建节点,值为str[mid]
tr[u].p = p; // 设置父节点
tr[u].s[0] = build(l, mid - 1, u); // 递归建立左子树
tr[u].s[1] = build(mid + 1, r, u); // 递归建立右子树
pushup(u); // 更新当前节点信息
return u; // 返回当前子树的根节点
}
// 查找中序遍历中第 k 个节点(k 从 1 开始计数)
// 这是序列维护型 Splay 树的核心查询操作
int kth(int k)
{
int x = root; // 从根开始查找
while (x)
{
pushdown(x); // 下传懒标记(保证子树大小信息正确)
if (tr[tr[x].s[0]].sz >= k) // 如果第k个节点在左子树中
{
x = tr[x].s[0]; // 进入左子树继续查找
}
else if (tr[tr[x].s[0]].sz + 1 == k) // 如果当前节点就是第k个
{
return x; // 返回当前节点编号
}
else // 第k个节点在右子树中
{
k -= tr[tr[x].s[0]].sz + 1; // 减去左子树和当前节点的个数
x = tr[x].s[1]; // 进入右子树继续查找
}
}
return -1; // 理论上不会到达这里
}
// 单点修改操作:将第 pos 个位置的字符修改为 c
// 实现:找到第 pos+1 个节点(前面有哨兵),旋转到根,修改值,更新信息
void modify(int pos, char c)
{
int x = kth(pos + 1); // 找到第 pos+1 个节点(前面有哨兵节点)
splay(x, 0); // 将该节点旋转到根
tr[x].val = c; // 修改节点字符值
pushup(x); // 更新该节点的哈希信息
}
// 区间翻转操作:将区间 [l, r] 内的子串反转
// 实现:将第 l-1 个节点旋转到根,第 r+1 个节点旋转到根的右儿子
// 这样区间 [l,r] 就对应第 r+1 个节点的左子树,对其打翻转标记即可
void reverse(int l, int r)
{
int x = kth(l); // 第 l 个节点(左边界哨兵的前一个)
int y = kth(r + 2); // 第 r+2 个节点(右边界哨兵的后一个)
splay(x, 0); // 将x旋转到根
splay(y, x); // 将y旋转到x的右儿子
pushrev(tr[y].s[0]); // 对区间 [l,r] 对应的子树(y的左子树)打翻转标记
pushup(y); // 更新y的信息
pushup(x); // 更新x的信息
}
// 回文查询操作:判断区间 [l, r] 是否为回文串
// 实现:提取区间 [l,r] 对应的子树,比较其正向哈希和反向哈希
bool query(int l, int r)
{
int x = kth(l); // 找到左边界节点
int y = kth(r + 2); // 找到右边界节点
splay(x, 0); // 将x旋转到根
splay(y, x); // 将y旋转到x的右儿子
int z = tr[y].s[0]; // z为区间 [l,r] 对应的子树根节点
return tr[z].h1 == tr[z].h2; // 正向哈希等于反向哈希则为回文
}
int main()
{
ios::sync_with_stdio(false); // 关闭cin与stdio同步,加速输入
cin.tie(0); // 解除cin与cout绑定,进一步加速
srand(time(0)); // 初始化随机种子(未使用)
// 预处理底数的幂次数组:pow_base[i] = base^i
pow_base[0] = 1;
for (int i = 1; i < MAXN; ++i)
{
pow_base[i] = pow_base[i - 1] * base;
}
cin >> n >> q; // 读入基因序列长度n和操作次数q
cin >> (str + 2); // 读入基因序列,从str[2]开始存储
str[1] = 'X'; // str[1]为左哨兵字符(不参与实际序列)
str[n + 2] = 'X'; // str[n+2]为右哨兵字符(不参与实际序列)
root = build(1, n + 2, 0); // 建立Splay树,包含序列和左右哨兵
while (q--) // 依次处理每个操作
{
int op, l, r, p; // op为操作类型,l,r为区间,p为位置
char c; // c为修改后的字符
cin >> op;
if (op == 1) // 操作1:单点突变
{
cin >> p >> c; // 读入位置p和字符c
modify(p, c); // 执行单点修改
}
else if (op == 2) // 操作2:片段逆转
{
cin >> l >> r; // 读入区间[l,r]
reverse(l, r); // 执行区间翻转
}
else if (op == 3) // 操作3:变异检测(回文查询)
{
cin >> l >> r; // 读入查询区间[l,r]
cout << (query(l, r) ? "YES" : "NO") << '\n'; // 输出查询结果
}
}
return 0;
}
【运行结果】
1 1
A
3 1 1
YES
浙公网安备 33010602011771号