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

引入

CF915E. Physical Education Lessons

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

\(n \le 10^9\)\(q \le 3 \times 10^5\)

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

时空复杂度

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

实现

节点

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

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

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

新建节点(newNode)

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

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

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

怎么初始化根据题目而定。对于例题,由于一开始序列中全是 \(1\),所以把 \(sum\) 初始化为 \(r - l + 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(q \log q)\) 的,\(O(q \log n)\) 的动态开点线段树要卡卡常才能过。除此之外,本题的空间限制还非常紧,真的开到 \(4q \log n\) 是不行的,得开小一点。

例题

I. CF803G Periodic RMQ Problem

一发过,好耶!

区间赋值+查询区间最小值。比较裸,唯一需要注意的是初始化:我们想要快速知道 \([l, r]\) 这一段区间的最小值,而 \(1 \le l \le r \le nk\)。分三种情况讨论:\(l\)\(r\) 在同一块中;\(l\)\(r\) 在相邻的两块中,\(l\)\(r\) 在不相邻的两块中。用 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\) 最多只有 \(10^5\)。)

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

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

posted @ 2024-08-23 11:46  DengStar  阅读(1412)  评论(0)    收藏  举报