例题

已知一个数列,你需要进行下面两种操作: 将某区间每一个数加上 k 求出某区间每一个数的和。

引入

线段树是算法竞赛中常用的用来维护 区间信息 的数据结构。

线段树可以在 $O(logN)$ 的时间复杂度内实现单点修改、区间修改、区间查询(区间求和,求区间最大值,求区间最小值)等操作。

条件

线段树维护的信息可以认为是满足(幺)半群的性质

  • 封闭性
    • $\forall x \in S$, $\forall y \in S$ 有 $\forall x \circ y \in S$
  • 结合律
    • $\forall x,y,z \in S$ 有 $(x \circ y) \circ z = x \circ (y \circ z)$
  • 存在幺元
    • $\exist e \in S$ 满足 $\forall x \in S$ 有 $e \circ x = x$,$e$为左幺元;$x \circ e = x$,$e$为右幺元;

线段树的基本结构与建树

过程

线段树将每个长度不为1的区间划分成左右两个区间递归求解,把整个线段划分为一个树形结构,通过合并左右两区间信息来求得该区间的信息。这种数据结构可以方便的进行大部分的区间操作。

有个大小为5的数组 $a={10,11,12,13,14}$,要将其转化为线段树,有以下做法:设线段树的根节点编号为1,用数组 $d$ 来保存我们的线段树,$d_i$ 用来保存线段树上编号为 $i$ 的节点的值(这里每个节点所维护的值就是这个节点所表示的区间总和)。

image

图中每个节点中用红色字体标明的区间,表示该节点管辖的 $a$ 数组上的位置区间。如 $d_1$ 所管辖的区间就是 $[1,5] (a_1,a_2,…,a_5)$,即$d_1$所保存的值是$a_1+a_2+…+a_5$, $d_1=60$表示的是$a_1+a_2+…+a_5=60$。

通过观察不难发现,$d_i$的左儿子节点是$d_2i$,$d_i$的右儿子节点是$d_2i+1$。如果$d_i$表示的是区间$[s,t]$(即$a_s+a_{s+1}+…+a_t$)的话,那么$d_i$的左儿子节点表示的是区间$[s,\frac{s+t}{2}]$,$d_i$的右儿子节点表示的是区间$[\frac{s+t}{2}+1,t]$。

在实现时,我们考虑递归建树。设当前的根节点为 $p$,如果根节点管辖的区间长度已经是 1,则可以直接根据 $a$ 数组上相应位置的值初始化该节点。否则我们将该区间从中点处分割为两个子区间,分别进入左右子节点递归建树,最后合并两个子节点的信息。

实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void build(int s, int t, int p) {
  // 对 [s,t] 区间建立线段树,当前根的编号为 p
  if (s == t) {
    d[p] = a[s];
    return;
  }
  int m = s + ((t - s) >> 1);
  // 移位运算符的优先级小于加减法,所以加上括号
  // 如果写成 (s + t) >> 1 可能会超出 int 范围
  build(s, m, p * 2), build(m + 1, t, p * 2 + 1);
  // 递归对左右区间建树
  d[p] = d[p * 2] + d[(p * 2) + 1];
}

空间

关于线段树的空间:如果采用堆式存储( $2p$ 是 $p$ 的左儿子,$2p+1$ 是 $p$ 的右儿子),若有 $n$ 个叶子结点,则 $d$ 数组的范围最大为 $2^{[logn]+1}$。

分析:容易知道线段树的深度是 $[logn]$ 的,则在堆式储存情况下叶子节点(包括无用的叶子节点)数量为 $2^[logn]$ 个,又由于其为一棵完全二叉树,则其总节点个数 $2^{[logn]+1}-1$。当然如果你懒得计算的话可以直接把数组长度设为 4n,因为 $\frac{2^{[logn]+1}-1}{n}$的最大值在$n=2^x+1$时取到,此时节点数为 $2^{[logn]+1}-1=2^{x+2}-1=4n-5$。

线段树的区间查询

过程

区间查询,比如求区间 $[l.r]$ 的总和(即 $a_l+a_{l+1}+…+a_r$)、求区间最大值/最小值等操作。

仍然以最开始的图为例,如果要查询区间 $[1,5]$ 的和,那直接获取 $d_1$ 的值(60)即可。

如果要查询的区间为 $[3,5]$,此时就不能直接获取区间的值,但是 $[3,5]$ 可以拆成 $[3,3]$ 和 $[4,5]$,可以通过合并这两个区间的答案来求得这个区间的答案。

一般地,如果要查询的区间是 $[l,r]$,则可以将其拆成最多为 $O(logn)$ 个 极大 的区间,合并这些区间即可求出 $[l,r]$ 的答案。

实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int getsum(int l, int r, int s, int t, int p) {
  // [l, r] 为查询区间, [s, t] 为当前节点包含的区间, p 为当前节点的编号
  if (l <= s && t <= r)
    return d[p];  // 当前区间为询问区间的子集时直接返回当前区间的和
  int m = s + ((t - s) >> 1), sum = 0;
  if (l <= m) sum += getsum(l, r, s, m, p * 2);
  // 如果左儿子代表的区间 [s, m] 与询问区间有交集, 则递归查询左儿子
  if (r > m) sum += getsum(l, r, m + 1, t, p * 2 + 1);
  // 如果右儿子代表的区间 [m + 1, t] 与询问区间有交集, 则递归查询右儿子
  return sum;
}

线段树的区间修改与懒惰标记

过程

如果要求修改区间 $[l,r]$,把所有包含在区间 $[l,r]$ 中的节点都遍历一次、修改一次,时间复杂度无法承受。我们这里要引入一个叫做 「lazy标记」 的东西。

lazy标记,简单来说,就是通过延迟对节点信息的更改,从而减少可能不必要的操作次数。每次执行修改时,我们通过打标记的方法表明该节点对应的区间在某一次操作中被更改,但不更新该节点的子节点的信息。实质性的修改则在下一次访问带有标记的节点时才进行。

仍然以最开始的图为例,我们将执行若干次给区间内的数加上一个值的操作。我们现在给每个节点增加一个 $t_i$,表示该节点带的标记值。

image

现在我们准备给 $[3,5]$ 上的每个数都加上 $5$。根据前面区间查询的经验,我们很快找到了两个极大区间 $[3,3]$ 和 $[4,5]$(分别对应线段树上的 $3$ 号点和 $5$ 号点)。

我们直接在这两个节点上进行修改,并给它们打上标记:

image

我们发现,$3$ 号节点的信息虽然被修改了(因为该区间管辖两个数,所以 $d_3$ 加上的数是 $5*2=10$),但它的两个子节点却还没更新,仍然保留着修改之前的信息。不过不用担心,虽然修改目前还没进行,但当我们要查询这两个子节点的信息时,我们会利用标记修改这两个子节点的信息,使查询的结果依旧准确。

接下来我们查询一下 $[4,4]$ 区间上各数字的和。

我们通过递归找到 $[4,5]] 区间,发现该区间并非我们的目标区间,且该区间上还存在标记。这时候就到标记下放的时间了。我们将该区间的两个子区间的信息更新,并清除该区间上的标记。

image

现在 $6$、$7$ 两个节点的值变成了最新的值,查询的结果也是准确的。

实现

区间修改(加值)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void update(int l, int r, int c, int s, int t, int p) {
  // [l, r] 为修改区间, c 为被修改的元素的变化量, [s, t] 为当前节点包含的区间, p
  // 为当前节点的编号
  if (l <= s && t <= r) {
    d[p] += (t - s + 1) * c, b[p] += c;
    return;
  }  // 当前区间为修改区间的子集时直接修改当前节点的值,然后打标记,结束修改
  int m = s + ((t - s) >> 1);
  if (b[p] && s != t) {
    // 如果当前节点的懒标记非空,则更新当前节点两个子节点的值和懒标记值
    d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m);
    b[p * 2] += b[p], b[p * 2 + 1] += b[p];  // 将标记下传给子节点
    b[p] = 0;                                // 清空当前节点的标记
  }
  if (l <= m) update(l, r, c, s, m, p * 2);
  if (r > m) update(l, r, c, m + 1, t, p * 2 + 1);
  d[p] = d[p * 2] + d[p * 2 + 1];
}

区间查询(求和)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int getsum(int l, int r, int s, int t, int p) {
  // [l, r] 为查询区间, [s, t] 为当前节点包含的区间, p 为当前节点的编号
  if (l <= s && t <= r) return d[p];
  // 当前区间为询问区间的子集时直接返回当前区间的和
  int m = s + ((t - s) >> 1);
  if (b[p]) {
    // 如果当前节点的懒标记非空,则更新当前节点两个子节点的值和懒标记值
    d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m);
    b[p * 2] += b[p], b[p * 2 + 1] += b[p];  // 将标记下传给子节点
    b[p] = 0;                                // 清空当前节点的标记
  }
  int sum = 0;
  if (l <= m) sum = getsum(l, r, s, m, p * 2);
  if (r > m) sum += getsum(l, r, m + 1, t, p * 2 + 1);
  return sum;
}

动态开点线段树

前面讲到堆式储存的情况下,需要给线段树开 $4n$ 大小的数组。为了节省空间,我们可以不一次性建好树,而是在最初只建立一个根结点代表整个区间。当我们需要访问某个子区间时,才建立代表这个区间的子结点。这样我们不再使用 $2p$ 和 $2p+1$ 代表 $p$ 结点的儿子,而是用 $ls$ 和 $rs$ 记录儿子的编号。总之,动态开点线段树的核心思想就是:结点只有在有需要的时候才被创建

单次操作的时间复杂度是不变的,为 $O(logn)$。由于每次操作都有可能创建并访问全新的一系列结点,因此 m 次单点操作后结点的数量规模是 $O(mlogn)$。最多也只需要 $2n-1$ 个结点,没有浪费。

实现

单点修改

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// root 表示整棵线段树的根结点;cnt 表示当前结点个数
int n, cnt, root;
int sum[n * 2], ls[n * 2], rs[n * 2];

// 用法:update(root, 1, n, x, f); 其中 x 为待修改节点的编号
void update(int& p, int s, int t, int x, int f) {  // 引用传参
  if (!p) p = ++cnt;  // 当结点为空时,创建一个新的结点
  if (s == t) {
    sum[p] += f;
    return;
  }
  int m = s + ((t - s) >> 1);
  if (x <= m)
    update(ls[p], s, m, x, f);
  else
    update(rs[p], m + 1, t, x, f);
  sum[p] = sum[ls[p]] + sum[rs[p]];  // pushup
}

区间询问

1
2
3
4
5
6
7
8
9
// 用法:query(root, 1, n, l, r);
int query(int p, int s, int t, int l, int r) {
  if (!p) return 0;  // 如果结点为空,返回 0
  if (s >= l && t <= r) return sum[p];
  int m = s + ((t - s) >> 1), ans = 0;
  if (l <= m) ans += query(ls[p], s, m, l, r);
  if (r > m) ans += query(rs[p], m + 1, t, l, r);
  return ans;
}

区间修改也是一样的,不过下放标记时要注意如果缺少孩子,就直接创建一个新的孩子。或者使用标记永久化技巧。

优化

  • 在叶子节点处无需下放懒惰标记,所以懒惰标记可以不下传到叶子节点。
  • 标记永久化技巧
    • 如果确定懒惰标记不会在中途被加到溢出(即超过了该类型数据所能表示的最大范围),那么就可以将标记永久化。标记永久化可以避免下传懒惰标记,只需在进行询问时把标记的影响加到答案当中,从而降低程序常数。具体如何处理与题目特性相关,需结合题目来写。这也是树套树和可持久化数据结构中会用到的一种技巧。

进阶 - 猫树

众所周知线段树可以支持高速查询某一段区间的信息和,比如区间最大子段和,区间和,区间矩阵的连乘积等等。

但是有一个问题在于普通线段树的区间询问在某些毒瘤的眼里可能还是有些慢了。

简单来说就是线段树建树的时候需要做 $O(n)$ 次合并操作,而每一次区间询问需要做 $O(logn)$ 次合并操作,询问区间和这种东西的时候还可以忍受,但是当我们需要询问区间线性基这种合并复杂度高达 $O(log^2\omega)$ 的信息的话,此时就算是做 O(logn) 次合并有些时候在时间上也是不可接受的。

线性基是一个数的集合,并且每个序列都拥有至少一个线性基,取线性基中若干个数异或起来可以得到原序列中的任何一个数。

而所谓“猫树”就是一种不支持修改,仅仅支持快速区间询问的一种静态线段树。 构造一棵这样的静态线段树需要 $O(nlogn)$ 次合并操作,但是此时的查询复杂度被加速至 $O(1)$ 次合并操作。

在处理线性基这样特殊的信息的时候甚至可以将复杂度降至 $O(nlog^2\omega)$。

原理

在查询 $[l,r]$ 这段区间的信息和的时候,将线段树树上代表 $[l,l]$ 的节点和代表 $[r,r]$ 这段区间的节点在线段树上的 LCA 求出来,设这个节点 $p$ 代表的区间为 $[L,R]$,我们会发现一些非常有趣的性质:

  1. $[L,R]$这个区间一定包含 $[l,r]$。显然,因为它既是 $l$ 的祖先又是 $$ 的祖先。
  2. $[l,r]$这个区间一定跨越 $[L,R]$ 的中点。由于 $p$ 是 $l$ 和 $r$ 的 LCA,这意味着 $p$ 的左儿子是 $l$ 的祖先而不是 $r$ 的祖先,$p$ 的右儿子是 $r$ 的祖先而不是 $l$ 的祖先。因此,$l$ 一定在 $[L,mid]$ 这个区间内,$r$ 一定在 $[mid,R]$ 这个区间内。 有了这两个性质,我们就可以将询问的复杂度降至 $O(1)$ 了。

实现

具体来讲我们建树的时候对于线段树树上的一个节点,设它代表的区间为 $(l,r]$。

不同于传统线段树在这个节点里只保留 $[l,r]$ 的和,我们在这个节点里面额外保存 $(l,mid]$ 的后缀和数组和 $(mid,r]$ 的前缀和数组。

这样的话建树的复杂度为 $T(n)=2T(n/2)+O(n)=O(nlogn)$ 同理空间复杂度也从原来的 $O(n)$ 变成了 $O(nlogn)$。

下面是最关键的询问了。

如果我们询问的区间是 $[l,r]$ 那么我们把代表 $[l,l]$ 的节点和代表 $[r,r]$ 的节点的 LCA 求出来,记为 $p$。

根据刚才的两个性质,$l,r$ 在 $p$ 所包含的区间之内并且一定跨越了 $p$ 的中点。

这意味这一个非常关键的事实是我们可以使用 $p$ 里面的前缀和数组和后缀和数组,将 $[l,r]$ 拆成 $[l,mid]+(mid,r]$ 从而拼出来 $[l,r]$ 这个区间。

而这个过程仅仅需要 $O(1)$ 次合并操作!

当然如何求LCA,已经在这上面的优化,这次就不说。