持久化数据结构能解决的问题

高效的查询数据在过去时间点的状态, 优点是不需要复制完整的数据结构, 节省空间

例如 g i t git git支持回滚的操作

条件

在本身数据结构的拓扑结构不会发生变化

持久化数据结构的算法原理

如果暴力的做法, 假设一共 m m m个版本, 算法时间复杂度和空间复杂度都是 m × n m \times n m×n, 效率很低

持久化数据结构核心思想: 只记录每个版本与上一个版本不同的地方

假设是可持久化线段树, 因为每次最多改变的节点数量是 log ⁡ n \log n logn级别, 那么空间和时间会优化到 n + m log ⁡ n n + m \log n n+mlogn级别, 非常高效

持久化Trie

能解决的问题

查询历史版本的字符串是否存在

算法步骤

假设插入一些字符串cat, rat, cab, fry
在这里插入图片描述
红色节点是根节点, 上图是插入两个字符串的情况
在这里插入图片描述
当前插入的是cab, 发现已经有 c c c节点, 但是并不是插入的是相同的字符串, 需要分裂, 同时发现也需要用到 a a a节点, 那么

  • c , a c, a c,a两个节点复制到当前版本
  • 再将 a a a指向原来的 t t t
  • 创建新的节点 b b b, 将 a a a指向 b b b

在这里插入图片描述
插入最后一个字符fry后的持久化Trie结构

  1. 首先需要指针指向第 i − 1 i - 1 i1个版本, p p p
  2. 新的版本开一个新的节点 q q q
  3. 判断 p p p是否是空节点, 如果不是空节点, 将 p p p克隆到 q q q, tr[q] = tr[p]

小写字母的持久化trie代码实现

#include <bits/stdc++.h>

using namespace std;

// 代表要插入的字符串数量和版本数量
const int N = 1e6 + 10, M = 1e5 + 10;

// idx代表分配的根节点
int root[M], idx;
// tr_idx代表分配的trie树的节点
int tr[N][26], tr_idx;

void insert(string &s) {
   
    // p是上一个版本的根节点
    int p = root[idx];
    root[++idx] = ++tr_idx;
    // q是当前版本的根节点
    int q = tr_idx;

    for (int i = 0; i < s.size(); ++i) {
   
        char c = s[i];

        // 非关键路径上的点指向上一个版本
        for (int k = 0; k < 26; ++k) {
   
            if (c - 'a' != k) tr[q][k] = tr[p][k];
        }

        tr[q][c - 'a'] = ++tr_idx;
        q = tr[q][c - 'a'];
        p = tr[p][c - 'a'];
    }
}

bool find(string &s, int ver) {
   
    int q = root[ver];
    for (int i = 0; i < s.size(); ++i) {
   
        char c = s[i];
        if (tr[q][c - 'a'] == 0) return false;
        q = tr[q][c - 'a'];
    }
    return true;
}


int main() {
   
    ios::sync_with_stdio(false);
    cin.tie(0);

    int op;
    while (cin >> op, op) {
   
        string s;
        if (op == 1) {
   
            cin >> s;
            insert(s);
        }
        else {
   
            int ver;
            cin >> s >> ver;
            cout << (find(s, ver) ? "Yes" : "No") << '\n';
        }
    }
    return 0;
}

例题

在这里插入图片描述
两个操作

  • 序列末尾添加一个数很简单, 单点修改, 几乎任何数据结构都能实现
  • 在区间 [ l , r ] [l, r] [l,r]找到一个 p p p, 使得 a p ⊕ a p + 1 ⊕ . . . ⊕ a n ⊕ x a_p \oplus a_{p + 1} \oplus ... \oplus a_n \oplus x apap+1...anx

假设计算出了异或前缀和 s n = a 1 ⊕ a 2 ⊕ a 3 ⊕ . . . ⊕ a n s_n = a_1 \oplus a_2 \oplus a_3 \oplus ... \oplus a_n sn=a1a2a3...an, 那么答案就是

s n ⊕ s p − 1 ⊕ x s_n \oplus s_{p - 1} \oplus x snsp1x

t = s n ⊕ x t = s_n \oplus x t=snx, 发现 t t t是定值, 目标变成了
s p − 1 ⊕ t ,    ( l ≤ p ≤ r ) s_{p - 1} \oplus t, \; (l \le p \le r) sp1t,(lpr)
最大

t t t的二进制表示是 t = x 0 x 1 x 2 . . . x k t = x_0x_1x_2...x_k t=x0x1x2...xk, 求异或的最大值可以使用贪心的思路, 将当前 t t t存储到trie中, 对于当前点 i i i, 每次尝试访问 ⊕ x i \oplus x_i xi节点, 这样当前位结果就是 1 1 1, 结果就会更大

(1)先考虑简单情况, 假设 l = 1 l = 1 l=1, 求的区间是 [ 1 , r ] [1, r] [1,r], 那么可以利用持久化Trie解决, 直接查询第 r r r个版本, 计算最大值即可

(2)但是实际情况是需要在 [ l , r ] [l, r] [l,r]之间计算最大值, 考虑如下情况
在这里插入图片描述
对于版本 r r r, 假设当前最优贪心路线是走左子树

但是因为有了 l l l的限制, 因此问题就变成了对于左子树来说是否至少存在一个点的编号是 ≥ l \ge l l, 这个条件等价于下标的最大值是否 ≥ l \ge l l, 因此可以在trie树的每个节点记录当前子树的编号的最大值, 这样问题就得到了解决

代码实现

#include <bits/stdc++.h>

using namespace std;

const int N = 6e5 + 10, K = 24;

// n相当于版本根节点的索引
int n, m;
int s[N];
int tr[N * K][2], idx;
int root[N];
int max_id[N * K];

void insert(int p, int q, int i, int k) {
   
    if (k < 0) {
   
        max_id[q] = i;
        return;
    }

    int val = s[i] >> k & 1;
    if (p) tr[q][val ^ 1] = tr[p][val ^ 1];
    tr[q][val] = ++idx;
    insert(tr[p][val], tr[q][val], i, k - 1);
    max_id[q] = max(max_id[tr[q][0]], max_id[tr[q][1]]);
}

int query(int ql, int qr, int val) {
   
    int u = root[qr];

    for (int i = K - 1; i >= 0; --i) {
   
        int v = val >> i & 1;
        if (max_id[tr[u][v ^ 1]] >= ql) u = tr[u][v ^ 1];
        else u = tr[u][v];
    }

    return val ^ s[max_id[u]];
}

int main() {
   
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    max_id[0] = -1;
    root[0] = ++idx;
    insert(0, root[0], 0, K - 1);

    for (int i = 1; i <= n; ++i) {
   
        int val;
        cin >> val;
        s[i] = s[i - 1] ^ val;
        root[i] = ++idx;
        insert(root[i - 1], root[i], i, K - 1);
    }

    char c;
    int l, r, val;
    while (m--) {
   
        cin >> c;
        if (c == 'A') {
   
            cin >> val;
            n++;
            root[n] = ++idx;
            s[n] = s[n - 1] ^ val;
            insert(root[n - 1], root[n], n, K - 1);
        }
        else {
   
            cin >> l >> r >> val;
            int ans = query(l - 1, r - 1, s[n] ^ val);
            cout << ans << '\n';
        }
    }

    return 0;
}

持久化线段树(主席树)

能解决的问题

给定序列, 查询 [ l , r ] [l, r] [l,r]区间内第 k k k大的数字

性质

  • 因为持久化线段树有多个版本, 不能用的存储方式, 需要动态开点, 也就是需要节点的时候将节点建立
  • 因为持久化线段树有多个版本, 普通线段树在执行区间修改的时候需要维护延迟标记, 但是持久化线段树难以维护, 因此持久化线段树一般不支持区间修改(除非标记永久化)

算法步骤

在这里插入图片描述
假设修改的是红***域圈住的点, 线段树最多修改 log ⁡ n \log n logn个点, 右边是复制过来新的点

持久化线段树记录的节点信息

struct Node {
   
    // 左右儿子节点的下标
    int ls, rs;
    // 当前节点区间的数字的个数
    int cnt;
};

模板示例代码

在这里插入图片描述

#include <bits/stdc++.h>

using namespace std;

const int N = 1e6 + 10, M = 1e6 + 10;

int n, m, w[N];
struct Node {
   
   int ls, rs;
   int val;
} tr[N * 4 + N * 20];
int root[N], idx;

int build(int l, int r) {
   
   int u = ++idx;
   tr[u] = {
   0, 0, 0};
   if (l == r) {
   
      tr[u].val = w[l];
      return u;
   }

   int mid = l + r >> 1;
   tr[u].ls = build(l, mid);
   tr[u].rs = build(mid + 1, r);

   return u;
}

int update(int p, int l, int r, int x, int val) {
   
   int u = ++idx;
   tr[u] = tr[p];
   if (l == r) {
   
      tr[u].val = val;
      return u;
   }

   int mid = l + r >> 1;
   if (x <= mid) tr[u].ls = update(tr[p].ls, l, mid, x, val);
   else tr[u].rs = update(tr[p].rs, mid + 1, r, x, val);

   return u;
}

int query(int p, int l, int r, int x) {
   
   if (l == r) return tr[p].val;
   int mid = l + r >> 1;

   if (x <= mid) return query(tr[p].ls, l, mid, x);
   return query(tr[p].rs, mid + 1, r, x);
}

int main() {
   
   ios::sync_with_stdio(false);
   cin.tie(0);

   cin >> n >> m;
   for (int i = 1; i <= n; ++i) cin >> w[i];

   root[0] = build(1, n);

   int v, op, p, c;
   for (int i = 1; i <= m; ++i) {
   
      cin >> v >> op >> p;
      if (op == 1) {
   
         cin >> c;
         root[i] = update(root[v], 1, n, p, c);
      }
      else {
   
         int ans = query(root[v], 1, n, p);
         cout << ans << '\n';
         root[i] = root[v];
      }
   }

   return 0;
}

例题

在这里插入图片描述
首先考虑暴力做法, m m m,次询问, 区间大小最坏情况下 n n n, 算法时间复杂度 O ( m ⋅ n log ⁡ n ) O(m \cdot n \log n) O(mnlogn), 无法通过

一般该问题有两种做法

  • 线段树套平衡树, 支持第 k k k大数字修改操作, 算法时间复杂度 O ( M log ⁡ 2 N ) O(M \log ^ 2 N) O(Mlog2N)
  • 持久化线段树, 算法时间复杂度 O ( N log ⁡ N ) O(N \log N) O(NlogN)

具体的算法实现

  1. 因为 A i ≤ 1 0 9 A_i \le 10 ^ 9 Ai109, 将所有数据进行离散化, 数据范围变到了 [ 0 , N − 1 ] [0, N - 1] [0,N1]
  2. 数值上建立线段树(权值线段树), 维护区间的一共有多少个数字

问题变成了如何在整体上求第 k k k小数?

(1) 假设不加限制求 [ 0 , n − 1 ] [0, n - 1] [0,n1]的第 k k k小数是多少
可以用整数二分实现, 具体的来说
在这里插入图片描述

  • 假设权值线段树维护区间 [ 0 , n − 1 ] [0, n - 1] [0,n1], 那么假设中点是 m i d mid mid
  • 求区间 [ 0 , m i d ] [0, mid] [0,mid]区间的数字个数假设是 c n t cnt cnt, 判断 c n t > = k cnt >= k cnt>=k是否成立
  • 如果 c n t > = k cnt >= k cnt>=k成立, 递归左边区间
  • 否则递归到右边区间

因为只有 log ⁡ n \log n logn层, 最多访问 log ⁡ n \log n logn个节点, 算法时间复杂度 O ( n log ⁡ n ) O(n \log n) O(nlogn)

(2) 但是要求在 [ l , r ] [l, r] [l,r]之间求第 k k k小数

  • 可以使用可持久化线段树, 直接查询第 r r r个版本
  • 并且因为持久化线段树的结构不会发生变化(线段树节点数量不会发生变化, 新增的点不过是在原基础上修改的点)
  • 具体的来说, 假设当前节点存储的是 t t t个数字, 新版本节点在原版本节点基础上存储了 t ′ t' t个数字, 那么变化量就是 t ′ − t t' - t tt个数字
  • 因此对于当前节点的节点数可以用前缀和思想, 节点数是 C r − C l − 1 C_{r} - C_{l - 1} CrCl1

代码实现

  • 线段树节点空间需要多开 M log ⁡ n M \log n Mlogn个区间, 因此线段树节点数量 4 N + M log ⁡ N 4N + M \log N 4N+MlogN
  • 插入节点的过程中, 遍历 log ⁡ n \log n logn个节点, 对这 log ⁡ n \log n logn个节点都要创建新的节点, 最终返回的是构建完的新的根节点, 也就是root[i]
  • 查询的时候利用二分算法, 如果当前左子树的差小于等于 k k k, 那么结果在左子树, 否则在右子树, 但是注意排名要变成 k − c n t k - cnt kcnt
  • 因为对离散化后建立权值持久化线段树, 设 t t t是离散化后的数的个数(也可以表示为离散化后的数值的最大值 + 1 +1 +1), 构建的线段树的范围是 [ 0 , t − 1 ] [0, t - 1] [0,t1]
#include <bits/stdc++.h>

using namespace std;

const int N = 1e5 + 10;

int n, m, w[N];
struct Node {
   
    // 左右儿子节点的下标
    int ls, rs;
    // 当前节点区间的数字的个数
    int cnt;
} tr[4 * N + 17 * N];
int root[N], idx;
vector<int> nums;

int find(int x) {
   
    return lower_bound(nums.begin(), nums.end(), x) - nums.begin();
}

void pushup(int u) {
   
    tr[u].cnt = tr[tr[u].ls].cnt + tr[tr[u].rs].cnt;
}

int build(int l, int r) {
   
    int p = ++idx;
    if (l == r) return p;

    int mid = l + r >> 1;
    tr[p].ls = build(l, mid);
    tr[p].rs = build(mid + 1, r);
    return p;
}

int insert(int p, int l, int r, int x) {
   
    int u = ++idx;
    tr[u] = tr[p];
    if (l == r) {
   
        tr[u].cnt++;
        return u;
    }

    int mid = l + r >> 1;
    if (x <= mid) tr[u].ls = insert(tr[p].ls, l, mid, x);
    if (x > mid) tr[u].rs = insert(tr[p].rs, mid + 1, r, x);

    pushup(u);
    return u;
}

// 查询区间第k小数
int query(int p, int q, int l, int r, int k) {
   
    if (l == r) return nums[l];

    int mid = l + r >> 1;
    // 当前版本q左边节点数 - 旧p版本左半边节点数
    int cnt = tr[tr[q].ls].cnt - tr[tr[p].ls].cnt;
    if (k <= cnt) return query(tr[p].ls, tr[q].ls, l, mid, k);

    return query(tr[p].rs, tr[q].rs, mid + 1, r, k - cnt);
}


int main() {
   
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (int i = 1; i <= n; ++i) {
   
        cin >> w[i];
        nums.push_back(w[i]);
    }

    sort(nums.begin(), nums.end());
    nums.erase(unique(nums.begin(), nums.end()), nums.end());

    int t = nums.size();
    root[0] = build(0, t - 1);

    // 构建持久化线段树, 当前版本的根节点在递归完成后返回
    for (int i = 1; i <= n; ++i) {
   
        root[i] = insert(root[i - 1], 0, t - 1, find(w[i]));
    }

    while (m--) {
   
        int l, r, k;
        cin >> l >> r >> k;
        cout << query(root[l - 1], root[r], 0, t - 1, k) << '\n';
    }

    return 0;
}