线段树进阶技巧I——动态开点线段树

引入

CF915E. Physical Education Lessons

题意:有一个长度为 n 的序列,初始全为 0。有 q 次操作,每次操作把区间 [l,r] 内的所有元素变为 01。求出每次操作后序列中 1 的数量。

n109q3×105

这题乍一看是线段树板子,直到你看到数据范围——n109。显然,我们无论如何都开不下这么多点。但相比起 nq 又特别小。回忆线段树修改操作的过程,每次操作只会访问 O(logn) 个点,q 次操作只会访问 O(qlogn) 个点。也就是说,如果 nq,大部分的点其实从未被访问过。那么,我们能否只存储那些被访问过的点,以减小空间复杂度呢?秉承这种思想,动态开点线段树就诞生了。

时空复杂度

一般情况下,我们的线段树采用堆式存储——id 的左子是 id×2,右子是 id×2+1。这种方法的优势是好写且便于理解,但是会产生很多完全无用的节点。下面先分析这种方法的时空复杂度。

空间复杂度:不妨假设 n=2kkN。这样,线段树构成一棵满二叉树,它有 k+1=O(logn) 层,总共 2k+11=O(n) 个节点。也就是说线段树的空间复杂度为 O(n)。这其实也说明了为什么线段树建树的时间复杂度是 O(n) ,而非 O(logn)

对于那些不是 2 的幂的 n,线段树有 logn+1 层,如果采用堆式存储,线段树是一棵完全二叉树,总节点个数为 2logn+11(这里包含了无用的节点。)这个值的上界是 4n5=O(n)。我们取 n=2x+1xN+,可以达到这个上界:此时 2logn+11=2x+21=4n5。这也是为什么一般情况下我们写线段树会开 4 倍空间。

(关于 n=2x+1 这个值的选取:当 n2 的幂时最省空间,在此基础上加 1,线段树不得不多一层,但有很多节点是空的,此时最费空间。)

综上所述,线段树的空间复杂度是 O(n)

时间复杂度:访问单个节点的时间复杂度是 O(1),我们只需求出每次操作访问的节点数量即可。

定理:在线段树上操作时,每层最多访问 4 个节点。

证明:这是一个不太严谨的证明。

设操作区间为 [L,R],节点的区间为 [l,r]。如果一个节点满足 [l,r][L,R],则称其为“完整节点”,否则称为“部分节点”。

如果一个节点是完整节点,我们就不会访问它的子节点了。否则如果是部分节点,我们最多访问它的 2 个子节点。又由于每层最多只有 2 个部分节点(这是显然的,因为操作区间 [L,R] 连续),这 2 个部分节点最多向下一层贡献 4 个节点。故得证。(参考:数据结构1 「在线段树中查询一个区间的复杂度为 O(log⁡N)」的证明

由于线段树的层数是 O(logn),且每层最多访问常数个节点,所以单次操作的时间复杂度是 O(logn)


下面讨论动态开点线段树的时空复杂度。

时间复杂度:和堆式存储线段树完全相同,单次操作的时间复杂度也是 O(logn),这显然。

空间复杂度:运用本文开头提到的做法,只建立需要的节点。每次操作访问 O(logn),所以新建的节点数量不会超过 O(logn)。总空间复杂度 O(qlogn)

“新建节点”的一种理解方式是:想象一棵完整的线段树,它真的有所有的节点。但一开始所有的节点都是虚的,表示它没有被建出来。每次操作时,把访问到的虚节点变成实的。

PS:实际上,即使 n 不是很大,动态开点线段树也可以省空间——堆式存储的点数最大是 4n5,而动态开点的点数最大是 2n1(不会证),少了一半。但是考虑到动态开点还要存子节点编号,以及代码难度比堆式存储高,所以当 n 较小时,没有用动态开点代替堆式存储的必要。

实现

节点

struct Node
{
    int lazy, sum, lson, rson;
}t[~];

与堆式存储的线段树不同的地方:要存放子节点的编号(lsonrson)。此外,如果题目比较卡空间,节点里面就不要存储它控制的区间 [l,r],而改为在函数下放时获取(见下)。

这里 t[~] 相当于一个内存池,里面开好了所有可能用掉的点。其中 ~ 是按需求而定的一个数,根据上文分析,大约是 4qlogn。(但这只是一个上界,一般用不完,空间紧的时候可以开小一点)

新建节点(newNode)

从内存池中获得一个新点。

int newNode(int &id, int l, int r)
{
    id = ++tot;
    t[id].sum = r - l + 1; // 初始化
    return id;
}

值得注意的是,新建节点时还要初始化这个节点。

怎么初始化根据题目而定。对于例题,由于一开始序列中全是 1,所以把 sum 初始化为 rl+1。对于有些题目,初始化则相对繁琐一些。

为什么新建节点时还要初始化呢?因为动态开点线段树不能 buildtree,所以 newNode 就承担了初始化的职能。在堆式存储的线段树中,我们可以一口气先建完所有叶子节点,别的节点都可以由子节点 update 过来。因此,对于一个新建的节点,我们必须直接把它初始化。

update 与 pushdown

与朴素的线段树没什么区别,只是把 id << 1id << 1 | 1 改成了 t[id].lsont[id].rson

需要注意的是,pushdown() 时可能要新建节点,所以要传 lr。以及我这个写法默认了 pushdownidlazy 非空(不是 -1)。

void update(int id)
{
    t[id].sum = t[t[id].lson].sum + t[t[id].rson].sum;
}

void pushdown(int id, int l, int r)
{
    int mid = (l + r) >> 1;
    if(!t[id].lson) newNode(t[id].lson, l, mid);
    if(!t[id].rson) newNode(t[id].rson, mid + 1, r);

    t[t[id].lson].lazy = t[id].lazy, t[t[id].lson].sum = t[id].lazy * (mid - l + 1);
    t[t[id].rson].lazy = t[id].lazy, t[t[id].rson].sum = t[id].lazy * (r - mid);
    t[id].lazy = -1;
}

区间修改

还是与堆式存储的线段树没什么区别,只是修改了子节点的表示方法。

除此之外,我们可能访问到未被建立的节点,需要把它建出来。

这里,访问到未被建立的节点的原因可能是:我只在 lazy 不为空的时候才 pushdown,这就导致 lazy 为空的时候左右子可能没有被建立。

另一种写法是无论 lazy 是否为空都 pushdown,这样就保证了访问到某个节点时它一定已被建立,但这样写似乎常数会大一些?

void change(int &id, int l, int r, int L, int R, int c)
{
    if(!id) newNode(id, l, r); // 新建节点
    if(L == l && R == r)
    {
        t[id].lazy = c;
        t[id].sum = c * (r - l + 1);
        return;
    }

    int mid = (l + r) >> 1;
    if(t[id].lazy != -1) pushdown(id, l, r); // 只在 lazy 不为空时 pushdown

    if(R <= mid) change(t[id].lson, l, mid, L, R, c);
    else if(L >= mid + 1) change(t[id].rson, mid + 1, r, L, R, c);
    else
    {
        change(t[id].lson, l, mid, L, mid, c);
        change(t[id].rson, mid + 1, r, mid + 1, R, c);
    }
    update(id);
}

区间查询(query)

注意到例题的查询是全局的,所以不用写区间查询()

如果要写区间查询,和区间修改并没有什么区别,略


例题代码

说明:官方做法是 O(qlogq) 的,O(qlogn) 的动态开点线段树要卡卡常才能过。除此之外,本题的空间限制还非常紧,真的开到 4qlogn 是不行的,得开小一点。

例题

I. CF803G Periodic RMQ Problem

一发过,好耶!

区间赋值+查询区间最小值。比较裸,唯一需要注意的是初始化:我们想要快速知道 [l,r] 这一段区间的最小值,而 1lrnk。分三种情况讨论:lr 在同一块中;lr 在相邻的两块中,lr 在不相邻的两块中。用 ST 表查询序列 b 上的最小值即可。详见代码。

void newNode(int &id, int l, int r)
{
    id = ++tot;
    int lid = (l - 1) / n + 1, rid = (r - 1) / n + 1;
    int ll = l % n ? l % n : n, rr = r % n ? r % n : n; // ll,rr分别表示l,r在所在块中的编号
    if(rid - lid > 1) t[id].mn = st.query(1, n);
    else if(rid - lid == 1) t[id].mn = min(st.query(ll, n), st.query(1, rr));
    else t[id].mn = st.query(ll, rr);
}

还有一个需要注意的点是 changequery 里面必须 pushdown,无论 lazy 是否为空,否则节点没有正确的初值,update 的时候会出错。

代码

II. P3313 [SDOI2014] 旅行

又是一发过,无敌了。

显然这题需要树剖。下面忽略树的形态,只考虑序列上的问题。

对于每一种宗教,建立一棵线段树。对于宗教 c 的线段树,如果某个城市的宗教不是 c,则它的权值为 0,这是自然的设定。

Tip:这种建立多棵线段树的想法有时是很有效的。

实现时,我们通常不是构建很多个 SegmentTree 的结构体(存不下),而是利用动态开点的思想,提前开一个内存池,包含所有的节点。所有线段树新建节点时,都从这个内存池里面拿。

此外,对于不同的线段树,用一个数组 rt[] 来存储它们的根。

下面讨论各个操作的做法。

  • CC 改变城市 x 的信仰为 c:在 x 原来宗教对应的线段树内把 x 的权值改为 0,在 c 对应的线段树内把 x 的权值改为 x 原先的权值。
  • CW 把城市 x 的评级调整为 w:在 x 的宗教的线段树内修改 x 的权值为 w
  • QS /QM 查询区间和/区间最大值:在对应线段树内直接查询即可。

需要注意以下初始化的问题。这题如果在 newNode 新建节点的时候初始化是很麻烦的,因为要查询区间最大值,还得写一个 ST 表(像上一道题一样)。不妨把初始序列看作全为 0,把赋初值的过程看作进行 n 次单点修改操作,这样就解决了这个问题。(上一道题不能这么做的原因是因为序列长度 nk 很大,而这题序列长度 n 最多只有 105。)

for(int i = 1; i <= n; i++) tr.change(rt[col[i]], 1, n, dfn[i], val[i]); // 初始化

代码(不知道为啥跑得比分块还慢)

posted @   DengStar  阅读(282)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示