[数据结构]可持久化线段树

#1.0 何为可持久化线段树


也有人叫它 “主席树”,参见 知乎讨论

至于为什么,别问,问就是枪毙加急许可。

(以上纯属笔者乱搞,下面正文开始)


都知道线段树可以进行单点、区间修改等操作,但是如果我们已经做了 \(114514\) 次操作,突然想要查询在第 \(2333\) 次修改后的信息呢?那我们就需要保存每一次修改的信息了,这也就是 “可持久化” 的意义。

嘛,总之就是这样,似乎没有多么麻烦的样子,下面来看怎么实现。

#2.0 如何实现

为了下面更方便叙述,这里用一个 简单的题目 作为例题。

如题,你需要维护这样的一个长度为 \(N\) 的数组,支持如下几种操作

  1. 在某个历史版本上修改某一个位置上的值
  2. 访问某个历史版本上的某一位置的值

此外,每进行一次操作(对于操作 \(2\),即为生成一个完全一样的版本,不作任何改动),就会生成一个新的版本。版本编号即为当前操作的编号(从 \(1\) 开始编号,版本 \(0\) 表示初始状态数组)

上面这个问题抛去“历史版本”不看,可以线段树单点修改维护,不多说,还是来看如何可持久化。

#2.1 朴素想法

最最最最最朴素的想法就是直接将整个修改过的线段树,复制一遍,存下来(话说笔者最初真没想到这个,直接跳过这一步了...),但是很显然这么做空间、时间都不允许。

#2.2 最终做法

没想到吧,就是这么快...小编也很惊讶,但事实就是这样。

我们注意到上面的操作存下来了许多没有用的信息,明明有很多点没有被修改,却也被复制了一遍,就好比明明你的学长在机房颓废,但你没有,教练却把所有人都训了一顿一样无理。

那怎么办?很简单,只存修改了的节点不就好了!

显然根节点一定会改变,那么递归时,如果左右儿子之一没改变,就直接连上就好了,只去递归被修改的子节点,对这个子节点复制一份,在这个副本上进行修改,并连接到这个副本。

当然,对于上题中的操作 \(2\),直接将该操作对应的根节点设置为要查询的那个历史版本就好了。

以上就是所有的额外操作了,非常简单对吧!

为了保证空间,所以建议采用 动态开点 的形式。

#2.3 完整代码

好久没写过单点查询单点修改的线段树了...突然有点忘了咋写...

const int N = 4000100;
const int INF = 0x3fffffff;

struct Node {
    int l, r;
    int ls, rs;
    int sum;
};
Node p[N << 2];

int n, a[N], cnt, rt[N], m;

void build(int k, int l, int r) {
    p[k].l = l, p[k].r = r;
    if (l == r) {p[k].sum = a[l]; return;}
    int mid = (l + r) >> 1;
    p[k].ls = ++ cnt;
    build(p[k].ls, l, mid);
    p[k].rs = ++ cnt;
    build(p[k].rs, mid + 1, r);
}

void modify(int t, int &k, int x, int c) {
    if (!k) k = ++ cnt;
    p[k].l = p[t].l, p[k].r = p[t].r;
    if (p[t].l == p[t].r) {p[k].sum = c; return;}
    int mid = (p[k].l + p[k].r) >> 1;
    if (x <= mid) {
        p[k].rs = p[t].rs;
        modify(p[t].ls, p[k].ls, x, c);
    } else {
        p[k].ls = p[t].ls;
        modify(p[t].rs, p[k].rs, x, c);
    }
}

int query(int k, int x) {
    if (p[k].l == p[k].r) return p[k].sum;
    int mid = (p[k].l + p[k].r) >> 1;
    if (x <= mid) return query(p[k].ls, x);
    else return query(p[k].rs, x);
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++)
      scanf("%d", &a[i]);
    build(rt[0] = ++ cnt, 1, n);
    int v, op, pos, val;
    for (int i = 1; i <= m; i ++) {
        scanf("%d%d%d", &v, &op, &pos);
        if (op == 1) {
            scanf("%d", &val);
            modify(rt[v], rt[i], pos, val);
        } else {
            rt[i] = rt[v];
            printf("%d\n", query(rt[v], pos));
        }
    }
    return 0;
}

#3.0 主席树

在文章的开头,我们就提到了“主席树”这个称呼,有一点是比较确定的,“主席树”指的是可持久化权值线段树

#3.1 经典问题

来看一个主席树的经典问题吧:静态区间第 \(k\)

给定 \(n\) 个整数构成的序列 \(a\),将对于指定的闭区间 \([l,r]\) 查询其区间内的第 \(k\) 小值。

这是一个主席树的经典应用,我们考虑将序列中的数一个一个依次插入主席树,那么,我们不难发现,在插入第 \(r\) 个数后的主席树减去插入第 \(l-1\) 个数后的主席树形成的线段树便是只包含了区间 \([l,r]\) 中的数,那么可以用权值线段树查询区间第 \(k\) 小这一基本操作来解决这题。

#3.2 代码实现

首先是单点修改,维护区间和,这个操作与可持久化线段树的操作的基本构架相同。

void modify(int lt, int &k, int l, int r, int x) {
    if (!k) k = ++ cnt;
    p[k].sum = p[lt].sum + 1;
    /*因为是维护区间数的个数
    所以可以直接更改,不必 pushup*/
    if (l == r) return;
    int mid = (l + r) >> 1;
    if (x <= mid) {
        p[k].rs = p[lt].rs;
        modify(p[lt].ls, p[k].ls, l, mid, x);
    } else {
        p[k].ls = p[lt].ls;
        modify(p[lt].rs, p[k].rs, mid + 1, r, x);
    }
}

然后是查询操作。这里上文提到的“减去”操作就是减法操作,我们可以得到分离出的线段树的左子树上数的个数 sum_l ,当前寻找第 k 小,如果 sum_l>=k,那么就意味着,第 k 小在左子树,应当递归左子树,k 不变;否则第 k 小在右子树,应当递归右子树,并令 k=k-sum_l;重复以上操作,直到递归到叶节点。

int query(int lt, int k, int l, int r, int x) {
    if (l == r) return l;
    int mid = (l + r) >> 1;
    int sum_l = p[p[k].ls].sum - p[p[lt].ls].sum;
    if (sum_l >= x) return query(p[lt].ls, p[k].ls, l, mid, x);
    else return query(p[lt].rs, p[k].rs, mid + 1, r, x - sum_l);
}

最主要的两个操作也就是这些,因为 \(a\) 中的数的大小可能很大,需要进行离散化处理,这里附上完整程序。

对于每一个询问,我们有以下两种处理方式:

  1. 将询问离线,按右端点排序,在插入每个数后检查是否有右端点为此数的,如果有,进行查询操作。
  2. 将所有数一并插入,依次处理每个询问。

这里采用第一种处理方法。

const int N = 1000100;
const int INF = 0x3fffffff;

struct Node {
    int ls, rs;
    int sum;
};
Node p[N << 2];

struct Ques {
    int l, r, k, idx;
    bool operator < (const Ques &b) const {
        return r < b.r;
    }
};
Ques q[N];

int n, m, a[N], cnt, t[N], tot, rt[N], ans[N];

inline void pushup(int k) {
    p[k].sum = p[p[k].ls].sum + p[p[k].rs].sum;
}

void build(int &k, int l, int r) {
    if (!k) k = ++ cnt;
    if (l == r) return;
    int mid = (l + r) >> 1;
    build(p[k].ls, l, mid);
    build(p[k].rs, mid + 1, r);
}

void modify(int lt, int &k, int l, int r, int x) {
    if (!k) k = ++ cnt;
    p[k].sum = p[lt].sum + 1;
    if (l == r) return;
    int mid = (l + r) >> 1;
    if (x <= mid) {
        p[k].rs = p[lt].rs;
        modify(p[lt].ls, p[k].ls, l, mid, x);
    } else {
        p[k].ls = p[lt].ls;
        modify(p[lt].rs, p[k].rs, mid + 1, r, x);
    }
}

int query(int lt, int k, int l, int r, int x) {
    if (l == r) return l;
    int mid = (l + r) >> 1;
    int sum_l = p[p[k].ls].sum - p[p[lt].ls].sum;
    if (sum_l >= x) return query(p[lt].ls, p[k].ls, l, mid, x);
    else return query(p[lt].rs, p[k].rs, mid + 1, r, x - sum_l);
}

inline int mpg(int x) { //离散化后进行映射
    return lower_bound(t + 1, t + tot + 1, x) - t;
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++) {
        scanf("%d", &a[i]);
        t[i] = a[i];
    }
    for (int i = 1; i <= m; i++) {
        scanf("%d%d%d", &q[i].l, &q[i].r, &q[i].k);
        q[i].idx = i;
    }
    /*对询问排序及对 a 进行离散化*/
    sort(q + 1, q + m + 1);
    sort(t + 1, t + n + 1);
    tot = unique(t + 1, t + n + 1) - t - 1;
    build(rt[0], 1, tot); //建空树
    for (int i = 1, j = 1; i <= n; i ++) {
        modify(rt[i - 1], rt[i], 1, tot, mpg(a[i]));
        while (q[j].r == i)
          ans[q[j].idx] = query(rt[q[j].l - 1], rt[i], 1, tot, q[j].k), j ++;
    }
    for (int i = 1; i <= m; i ++)
      printf("%d\n", t[ans[i]]);
    return 0;
}

参考资料

[1] 良心的可持久化线段树教程 - 胡小兔

[2] 可持久化线段树 - OI Wiki

posted @ 2021-07-05 09:36  Dfkuaid  阅读(74)  评论(0编辑  收藏  举报