线段树进阶技巧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(logN)」的证明)
由于线段树的层数是 \(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[~];
与堆式存储的线段树不同的地方:要存放子节点的编号(lson,rson)。此外,如果题目比较卡空间,节点里面就不要存储它控制的区间 \([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 << 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)
注意到例题的查询是全局的,所以不用写区间查询()
如果要写区间查询,和区间修改并没有什么区别,略
说明:官方做法是 \(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);
}
还有一个需要注意的点是 change 和 query 里面必须 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]); // 初始化
代码(不知道为啥跑得比分块还慢)

浙公网安备 33010602011771号