瑞瑞的基因编译

【题目来源】

瑞学堂:瑞瑞的基因编译

【题目描述】

瑞瑞在虚拟实验室中进行一项高级基因编译实验。基因序列可以表示为一个长度为 \(n\) 的字符串 \(S\),仅包含字母 ATCG

实验室的设备支持三种操作来修改基因序列:

  1. 单点突变:将下标 \(p\) 位置的字符修改为给定的字符 \(c\)
  2. 片段逆转:选择一段区间 \([l,r]\),将该区间内的基因片段进行反向转译。具体来说,对于该区间内的每一个位置 \(i(l≤i≤r)\),将其字符替换为该区间内从右往左数对应位置的字符。这个操作等同于将子串 \(S[l…r]\) 原地反转。
  3. 变异检测:给定一个区间 \([l,r]\),询问当前基因序列在该区间内的片段 \(S[l…r]\) 是否是回文序列。回文序列是指正着读和反着读完全相同的序列,例如 ATTA 是回文,而 ATCG 不是。

瑞瑞需要按顺序执行 \(q\) 个操作。请你帮助他模拟这个过程,并对于每个类型 \(3\) 的操作(变异检测),输出 YES 或 NO

【输入】

第一行包含两个整数 \(n\) 和 \(q\),表示基因序列的长度和操作次数。
第二行包含一个长度为 \(n\) 的字符串 \(S\),仅由大写字母 ATCG 组成,下标从 \(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

【核心思想】

  1. 问题分析:给定长度为 \(n\) 的字符串 \(S\)(仅含 A/T/C/G),支持三种操作:单点修改、区间反转、区间回文查询。需要在线处理 \(q\) 个操作。这是一个序列维护型 Splay 树 + 双哈希问题,关键在于用 Splay 树维护序列顺序,支持 \(O(\log n)\) 的区间提取、修改、翻转,并用正向/反向哈希 \(O(1)\) 判断回文。

  2. 算法选择

    • Splay 树(序列维护):通过中序遍历维护序列顺序,支持通过 kth 查找第 \(k\) 个元素,通过 splay 旋转提取任意区间
    • 双哈希(正向 + 反向):每个节点维护子树的正向哈希 \(h_1\)(从左到右)和反向哈希 \(h_2\)(从右到左),区间回文判断等价于 \(h_1 = h_2\)
    • 懒标记翻转:区间反转操作通过 pushrev 交换左右子树和双向哈希,延迟下传保证 \(O(\log n)\)
  3. 关键步骤

    • 建树:递归建立平衡二叉树,中序遍历即为原序列,首尾加入哨兵节点方便区间操作
    • 区间提取模板:对于区间 \([l, r]\)
      • splay(kth(l), 0) 将第 \(l-1\) 个节点(左哨兵)旋到根
      • splay(kth(r+2), root) 将第 \(r+1\) 个节点(右哨兵)旋到根的右儿子
      • 目标区间即为根右儿子的左子树
    • 单点修改:提取位置 \(pos\) 到根,直接修改 valpushup
    • 区间翻转:提取区间后,对目标子树根打 pushrev 懒标记
    • 回文查询:提取区间后,比较目标子树的 \(h_1\)\(h_2\)
    • 哈希维护pushup 时按 \(h_1 = left \cdot base^{right+1} + val \cdot base^{right} + right\) 计算
  4. 时间/空间复杂度

    • 时间复杂度:\(O((n+q) \log n)\),每次操作均摊 \(O(\log n)\)
    • 空间复杂度:\(O(n)\),Splay 树节点数组
  5. 序列 Splay + 双哈希的核心思想

    • 中序遍历即序列:Splay 树的中序遍历结果对应原序列,通过 kthsplay 实现序列上的任意区间操作
    • 哨兵简化边界:在序列首尾加入哨兵节点,使得任意区间 \([l, r]\) 都可统一表示为"左哨兵的右子树中、右哨兵的左子树"
    • 双向哈希判回文:正向哈希与反向哈希相等是回文的充要条件(无冲突时),将 \(O(n)\) 的逐字符比较降为 \(O(1)\) 的哈希比较
    • 懒标记维护反转:翻转懒标记延迟下传,交换左右子树时同步交换双向哈希,保证反转后哈希值正确
    • 适用于"序列上的区间修改、翻转、查询"问题,核心在于将序列操作映射为平衡树上的结构操作

【算法标签】

平衡树

【代码详解】

#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
posted @ 2026-08-07 15:27  团爸讲算法  阅读(3)  评论(0)    收藏  举报