线段树进阶技巧I——动态开点线段树
引入
CF915E. Physical Education Lessons
题意:有一个长度为 的序列,初始全为 。有 次操作,每次操作把区间 内的所有元素变为 或 。求出每次操作后序列中 的数量。
,。
这题乍一看是线段树板子,直到你看到数据范围——。显然,我们无论如何都开不下这么多点。但相比起 , 又特别小。回忆线段树修改操作的过程,每次操作只会访问 个点, 次操作只会访问 个点。也就是说,如果 ,大部分的点其实从未被访问过。那么,我们能否只存储那些被访问过的点,以减小空间复杂度呢?秉承这种思想,动态开点线段树就诞生了。
时空复杂度
一般情况下,我们的线段树采用堆式存储—— 的左子是 ,右子是 。这种方法的优势是好写且便于理解,但是会产生很多完全无用的节点。下面先分析这种方法的时空复杂度。
空间复杂度:不妨假设 ,。这样,线段树构成一棵满二叉树,它有 层,总共 个节点。也就是说线段树的空间复杂度为 。这其实也说明了为什么线段树建树的时间复杂度是 ,而非 。
对于那些不是 的幂的 ,线段树有 层,如果采用堆式存储,线段树是一棵完全二叉树,总节点个数为 (这里包含了无用的节点。)这个值的上界是 。我们取 ,,可以达到这个上界:此时 。这也是为什么一般情况下我们写线段树会开 倍空间。
(关于 这个值的选取:当 是 的幂时最省空间,在此基础上加 ,线段树不得不多一层,但有很多节点是空的,此时最费空间。)
综上所述,线段树的空间复杂度是 。
时间复杂度:访问单个节点的时间复杂度是 ,我们只需求出每次操作访问的节点数量即可。
定理:在线段树上操作时,每层最多访问 个节点。
证明:这是一个不太严谨的证明。
设操作区间为 ,节点的区间为 。如果一个节点满足 ,则称其为“完整节点”,否则称为“部分节点”。
如果一个节点是完整节点,我们就不会访问它的子节点了。否则如果是部分节点,我们最多访问它的 个子节点。又由于每层最多只有 个部分节点(这是显然的,因为操作区间 连续),这 个部分节点最多向下一层贡献 个节点。故得证。(参考:数据结构1 「在线段树中查询一个区间的复杂度为 O(logN)」的证明)
由于线段树的层数是 ,且每层最多访问常数个节点,所以单次操作的时间复杂度是 。
下面讨论动态开点线段树的时空复杂度。
时间复杂度:和堆式存储线段树完全相同,单次操作的时间复杂度也是 ,这显然。
空间复杂度:运用本文开头提到的做法,只建立需要的节点。每次操作访问 ,所以新建的节点数量不会超过 。总空间复杂度 。
“新建节点”的一种理解方式是:想象一棵完整的线段树,它真的有所有的节点。但一开始所有的节点都是虚的,表示它没有被建出来。每次操作时,把访问到的虚节点变成实的。
PS:实际上,即使 不是很大,动态开点线段树也可以省空间——堆式存储的点数最大是 ,而动态开点的点数最大是 (不会证),少了一半。但是考虑到动态开点还要存子节点编号,以及代码难度比堆式存储高,所以当 较小时,没有用动态开点代替堆式存储的必要。
实现
节点
struct Node
{
int lazy, sum, lson, rson;
}t[~];
与堆式存储的线段树不同的地方:要存放子节点的编号(lson
,rson
)。此外,如果题目比较卡空间,节点里面就不要存储它控制的区间 ,而改为在函数下放时获取(见下)。
这里 t[~]
相当于一个内存池,里面开好了所有可能用掉的点。其中 ~
是按需求而定的一个数,根据上文分析,大约是 。(但这只是一个上界,一般用不完,空间紧的时候可以开小一点)
新建节点(newNode)
从内存池中获得一个新点。
int newNode(int &id, int l, int r)
{
id = ++tot;
t[id].sum = r - l + 1; // 初始化
return id;
}
值得注意的是,新建节点时还要初始化这个节点。
怎么初始化根据题目而定。对于例题,由于一开始序列中全是 ,所以把 初始化为 。对于有些题目,初始化则相对繁琐一些。
为什么新建节点时还要初始化呢?因为动态开点线段树不能 buildtree
,所以 newNode
就承担了初始化的职能。在堆式存储的线段树中,我们可以一口气先建完所有叶子节点,别的节点都可以由子节点 update
过来。因此,对于一个新建的节点,我们必须直接把它初始化。
update 与 pushdown
与朴素的线段树没什么区别,只是把 id << 1
和 id << 1 | 1
改成了 t[id].lson
和 t[id].rson
。
需要注意的是,pushdown()
时可能要新建节点,所以要传 l
和 r
。以及我这个写法默认了 pushdown
时 id
的 lazy
非空(不是 -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)
注意到例题的查询是全局的,所以不用写区间查询()
如果要写区间查询,和区间修改并没有什么区别,略
说明:官方做法是 的, 的动态开点线段树要卡卡常才能过。除此之外,本题的空间限制还非常紧,真的开到 是不行的,得开小一点。
例题
I. CF803G Periodic RMQ Problem
一发过,好耶!
区间赋值+查询区间最小值。比较裸,唯一需要注意的是初始化:我们想要快速知道 这一段区间的最小值,而 。分三种情况讨论:, 在同一块中;, 在相邻的两块中,, 在不相邻的两块中。用 ST 表查询序列 上的最小值即可。详见代码。
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);
}
还有一个需要注意的点是 change
和 query
里面必须 pushdown
,无论 lazy
是否为空,否则节点没有正确的初值,update
的时候会出错。
II. P3313 [SDOI2014] 旅行
又是一发过,无敌了。
显然这题需要树剖。下面忽略树的形态,只考虑序列上的问题。
对于每一种宗教,建立一棵线段树。对于宗教 的线段树,如果某个城市的宗教不是 ,则它的权值为 ,这是自然的设定。
Tip:这种建立多棵线段树的想法有时是很有效的。
实现时,我们通常不是构建很多个
SegmentTree
的结构体(存不下),而是利用动态开点的思想,提前开一个内存池,包含所有的节点。所有线段树新建节点时,都从这个内存池里面拿。此外,对于不同的线段树,用一个数组
rt[]
来存储它们的根。
下面讨论各个操作的做法。
CC
改变城市 的信仰为 :在 原来宗教对应的线段树内把 的权值改为 ,在 对应的线段树内把 的权值改为 原先的权值。CW
把城市 的评级调整为 :在 的宗教的线段树内修改 的权值为 。QS
/QM
查询区间和/区间最大值:在对应线段树内直接查询即可。
需要注意以下初始化的问题。这题如果在 newNode
新建节点的时候初始化是很麻烦的,因为要查询区间最大值,还得写一个 ST 表(像上一道题一样)。不妨把初始序列看作全为 ,把赋初值的过程看作进行 次单点修改操作,这样就解决了这个问题。(上一道题不能这么做的原因是因为序列长度 很大,而这题序列长度 最多只有 。)
for(int i = 1; i <= n; i++) tr.change(rt[col[i]], 1, n, dfn[i], val[i]); // 初始化
代码(不知道为啥跑得比分块还慢)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】