一种节省空间的基于递归的线段树实现

使用先序遍历的节点访问顺序重新给左右儿子编号,大大节省了空间

自古以来,实现线段树所需要的数组空间大小有着比较激烈的争论。一种基于递归的线段树实现认为至少需要 O(4n)的空间,而另一种基于直接循环的方法认为其实只需要2n−1的空间。关于为什么基于递归的方法需要 O(4n)的严格证明可以去网上搜索,或者 这里 提供了一个简易的证明方法。总的来说,基于递归的线段树实现起来更加直观,但由于 dummy node 的存在造成了空间的浪费。而基于直接循环的方法对实现的要求更高。本文介绍了一个基于递归的线段树实现,通过改变每个节点的左右儿子的编号高效的利用了空间,使得基于递归的线段树也只需要 2n−1的空间。另外,关于基于循环的线段树实现方法,可以参考 这篇博客

传统实现

我们考虑一个恰好为 2 k 2^k 2k 的序列,由于长度恰好为 2 次幂,因此线段树的每个节点的长度恰好是它父亲节点的一半,因此最后形成的线段树是一颗满二叉树。比如下图是一颗长度为 8 的序列组成的线段树,维护了区间 min 的查询。
在这里插入图片描述
我们还可以注意到,如果根节点的编号是 1 的话,那么对于每个非叶子节点 x,它的左儿子是 2x,右儿子是 2x+1。整个线段树的节点个数是 2n−1。我们的 build () 函数可以大致这么实现。

template<typename Container>
T build(const Container &a, int cur, int left, int right) {
    
    
    if (left == right) {
    
    
        return tree [cur] = a [left];
    }
    int mid = (left + right) >> 1;      // 划分区间 
    int lc = cur << 1;                  // 左儿子的编号 
    int rc = cur << 1 | 1;              // 右儿子的编号 
    T l = build (a, lc, left, mid);      // 遍历左子树 
    T r = build (a, rc, mid + 1, right); // 遍历右子树 
    return tree [cur] = combine (l, r);
}

那么很自然地我们就会想到,如果对于一个长度不是 2 k 2^k 2k
的序列,上述结论是否依然成立呢?答案是显然的,很自然地,我们依然可以将每个非叶子节点的左儿子设置成 2x,右节点设置成 2x+1。那么对于一颗长度为 10 的序列组成的线段树,就会变成下图所示
在这里插入图片描述
我们大致可以得出两个结论。第一,如果你去统计这颗线段树的节点个数,你会发现它依然是 2n−1。这个是很容易理解的,因为任何一个 [1,n] 的序列,它一定有 n−1 个分割点,那么要全部变成长度为 1 的叶子节点,它必然要经历 n−1 次分割,加上叶子节点的个数 n,总节点自然就是 2n−1 了。因此我们可以得出一个重要结论

长度为 n 的序列会形成 2n−1 个节点个数的线段树。

这个结论非常重要,之后我们还会用到,因此需要暂时把它记住。至于第二个结论,就是从图中我们发现有些节点是不存在的,比如 18-23 号节点,另外 24 和 25 号节点的编号实际上已经超过了 2n−1,这就是为什么本文开头提到基于递归的线段树需要 O(4n) 的空间的原因。
那么既然线段树的总节点树就是 2n−1,我们是否可以通过某种办法,把这些节点的编号重新映射一下,使得线段树中每个节点的编号都落在 2n−1 内,这样我们就不需要开 O(4n) 的空间了,并且每个空间也能充分得到利用了,岂不美哉?

按照先序遍历重新编号节点

答案是肯定的,比如我们可以按照整棵树的节点访问顺序,也就是先序遍历的顺序给节点重新编号。
按照先序遍历进行编号的线段树
我们不再使用 2x 和 2x+1 给左右儿子编号了,而是使用从根节点开始,实际访问每个节点的顺序,也就是先序遍历的顺序给每个节点编号,或者再换一个说法,就是 dfs 的访问顺序。这样每个节点在数组空间中的排列就是紧密的了,那我们就只需要开 2n−1 的数组大小就能完成整颗线段树的构建了。

那么说了那么多,问题来了。我在递归遍历的时候怎么求出左右儿子的编号呢?本来只要 2x 和 2x+1 就能很容易的求出了,现在这么一搞,我怎么知道左右儿子的编号是多少呢?难不成我还要搞一个全局计数器去记录节点编号吗?

并不需要。首先我们来看左儿子的编号,因为现在的编号顺序就是实际的访问顺序了,而左儿子恰恰就是要访问的下一个节点,那么显然,左儿子的编号就是 x+1 了。那么右儿子呢?其实也很简单,我们注意到,如果左儿子划分了 [l,r] 这个区间,那么实际上左儿子就是一颗划分了 [l,r] 序列的子线段树。还记得之前的结论吗?[l,r] 的区间长度是 r−l+1,划分了 r−l+1 的长度会形成 2(r−l+1)−1 个节点个数。因此如果当前节点是 x,它的左子树就有 2(r−l+1)−1 个节点,而右儿子恰好就是左子树全部遍历完之后的下一个节点,因此右儿子的节点编号就是 x+2(r−l+1)−1+1,也就是 x+2(r−l+1) 了。而l,r 这些值在我们遍历的时候都是自然维护了,所以计算左右儿子都不需要引入任何额外的变量。build () 代码如下所示

template<typename Container>
T build (const Container &a, int cur, int left, int right) {
    
    
    if (left == right) {
    
    
        return tree [cur] = a [left];
    }
    int mid = (left + right) >> 1;
-   int lc = cur << 1;
-   int rc = cur << 1 | 1;
+   int lc = cur + 1 ;
+   int rc = cur + ((mid - left + 1) << 1);
    T l = build (a, lc, left, mid);
    T r = build (a, rc, mid + 1, right);
    return tree [cur] = combine (l, r);
}

注意到相比之前的代码,我们仅仅是修改了左右儿子的节点编号 lc 和 rc 而已。

结论

虽然开 4n 的空间实际上并不会有什么太大的影响,但使用新的节点编号方法并不会增加任何代码的复杂度,相反还能节省一半的空间,因此在我看来这种方式更加优雅。从另一个角度来说,将线段树的数组空间排列紧密化可以更好地利用局部性原理,在某种意义上对性能有一定的提升。

猜你喜欢

转载自blog.csdn.net/younow22/article/details/113177803