线段树 (Segment Tree) 和树状数组是两种常用的数据结构。他们用来维护一个区间内的操作,可以在 \(logN\)
线段树可以维护对一个区间的查询和修改,可以对区间进行分块查询,而树状数组是线段树的阉割版,经常用来区间查询,但修改只能进行单点修改,经过改造之后可以区间修改,区间树本身就可以支持区间修改。使用树状数组的原因是因为树状数组比较好写。
两个数据结构的样子
树状数组:
区间树:
可以看到区间树是父亲节点维护子节点的信息,到了叶子结点才是具体的某一个值。
树状数组的构建则很独特,他的结点维护信息的数量是由其结点转化成二进制之后最左边的1的个数之后的零的个数决定的。
c8 = c4 + c6 + c7 + a8
c4 = c2 + c3 + a4
c6 = c5 + a6
c7 = a7
c2 = c1 + a2
c3 = a3
c5 = a5
c1 = a1
而
\(8_{10} = 1000_{2}\) \(6_{10} = {110}_2\)
所以我们可以看到,\(c8\) 结点维护了 \(2^3\) 个结点信息,\(c_6\) 结点维护了 \(2^1\)
树状数组的操作
构建
树状数组不需用进行构建,可以把树的构建当作是树的修改。
修改操作(单点修改)
树状数组的修改即修改维护这个节点信息的所有节点,更新与这个节点有关的所有节点即完成了节点的修改。
那么问题变成了如何寻找到与这个节点有关的上一个节点,只要找到上一个结点,那么我们就可以进行递归修改,从而完成对所有相关节点的修改。
而结点上寻可以看成对原来的数以二进制的形式加上除去除了最低位的 1 以外的二进制数后得到的结果。
若 \(a_1a_2a_3...a_i..a_j\) 为原来数的二进制表达形式,\(a_i\) 为最低位的1,那么其上寻节点应该为\(a_1a_2a_3...a_i..a_j + a_i..a_j\)。
比如从图中我们可以看到,\(c4\)的上一个结点是\(c8\),根据这个上寻规则 \(100_2\) 加上 \(100_2\) 得到\(1000_2\)。
\(c6\) 的上一个结点是\(c8\),根据上寻规则 \(110_2\) 加上 \(10_2\),得到\(1000_2\)。
\(c7\) 的上一个结点是\(c8\),根据规则 \(111_2\) 加上 \(1_2\) 得到\(1000_2\)。
那么问题就变成了如何保留某一个数的最低位的1,把其余所有的1去除。
这里,我们可以引入一个函数 lowbit(x)
int lowbit(x) {
return x & (-x)
}
这个函数的作用可以只保留 x 的最低位的1,将其高位的1去除,具体原因是计算机中的数据存储按照补码规则进行存储,可以推出。
比如
\(lowbit(4) = lowbit((100)_2) = 100_2 = 4_{10}\)
\(lowbit(5) = lowbit((101)_2) = 1_2\)
\(lowbit(6) = lowbit((110)_2) = 10_2 = 2_{10}\)
这样,我们就完成了整个的修改操作
int add(int x, int k){
while(x <= n){
tree[x] += k;
x += lowbit(x);
}
}
这里的 n 表示整个数组的长度。
查询操作 区间查询
树状数组的父亲节点维护的信息是一段前缀和的信息,如果需要进行区间查询,那么利用前缀和也可以求出区间查询的结果。
查询操作和修改操作相反,我们需要不断查询子节点,直到子节点为0。
如何求解子节点可以看成如何查找父亲节点的逆操作,父亲节点为加上lowbit值,那么子节点即为减去lowbit的值。
int sum(int x){
int ans = 0;
while (x != 0) {
ans += tree[x]
x -= lowbit(x);
}
return ans;
}
区间修改+单点查询
区间修改的操作,可以把树状数组维护的前i项和看成第i个数,那么对 \([x,y]\) 的区间修改,可以看成对第x个位置和第 y+1 的位置进行修改。
这样之后进行单点查询,即询问某个位置的值。如果查询范围在 \([0, x]\) 之间 或 \([y, +\infty]\),即为原来的数。如果查询范围在 \([x, y]\)
区间树的操作
构建
区间树需要我们进行建树操作,我们可以观察一下区间树的构成。
可以看到区间树的父亲节点维护的区间是左右儿子区间的并集,左右儿子节点的划分是父亲节点的中间元素,所以,我们可以采用这种方式进行递归建树。区间树的信息是由两个子节点的信息决定的,所以,我们需要在两个子节点建好之后,维护父亲节点的信息。
先定义两个函数,获取父亲节点的两个儿子节点
int leftChild(int p){
return p << 1;
}
int rightChild(int p) {
return p << 1 + 1;
}
void build(int p, int left, int right) {
if (left == right) {
ans[p] = a[left];
return ;
}
int mid = (left + right) / 2;
build(leftChild(p), left, mid);
build(rightChild(p), mid+1, right);
push_up(p);
}
上面的 push_up()
操作是用来维护父亲节点信息,这个信息可以是由两个子区间决定的区间和,区间最值等信息。但这个信息必须满足结合律。这里我们使用维护区间和。
void push_up(int p) {
ans[p] = ans[leftChild(p)] + ans[rightChild(p)];
return ;
}
区间修改操作
对于线段树而言,单点修改和区间修改没有什么具体的差别,无非是区间长度不一样而已,对于线段树,我们可以引入懒标记的操作,没有懒标记之前,我们需要进行区间维护需要先递归到叶子节点,然后向上依次维护父亲节点。而懒标记的意义在于他不是更新到每一个具体的叶子节点,而是先记录在部分区间的公共父亲节点上。然后需要更新的时候再更新。需要更新的时机主要是在什么时候会用到子节点信息,如果需要用到子节点信息,那么我们需要进行将 lazy tag
进行下放,保证了子节点信息的一致。
所以,如果当前区间已经被更新区间完全覆盖,那么我们不用对这个区间继续深入到各个子节点,可以直接在父亲节点完成对区间维护,如果当前区间被更新区间部分覆盖,那么我们就对父亲节点的两个子节点进行部分维护即可,在维护两个子节点的时候,因为父亲节点的 lazy tag
记录着上次的更新信息,所以,我们需要将父亲节点的 lazy tag
下降到两个子节点,更新两个子节点的信息后,才能对两个子节点进行这次的更新操作。不然,可能会出现数据不一致问题。
void push_down(int p, int left, int right) {
int mid = (left + right) >> 1;
tag[leftChild(p)] = tag[leftChild(p)] + tag[p];
ans[leftChild(p)] = ans[leftChild(p)] + tag[p] * (mid - left + 1);
tag[rightChild(p)] = tag[rightChild(p)] + tag[p];
ans[rightChild(p)] = ans[rightChild(p)] + tag[p] * (right - (mid + 1) +1);
//将父亲节点的 lazy tag 下降后,父亲节点的 lazy tag 清零
tag[p] = 0;
}
void update(int updateL, int updateR, int left, int right, int p, int k) {
//updateL, updateR 表示更新区间的范围
//left, right,p 表示当前区间的范围
//k 表示更新的值
if (updateL <= left && updateR >= right) {
//更新的区间完全覆盖了当前区间
//可以直接使用这个区间的懒标记
ans[p] += k*(left - right + 1);
tag[p] += k;
return;
}
//此时被更新的区间部分覆盖当前区间
push_down(p, l, r); // 因为要对子节点进行更新,所以把当前的父亲节点的 lazy tag 向下进行传递
int mid = (left + right) / 2;
if (updateL <= mid){
//更新的区间部分覆盖左儿子区间
update(updateL, updateR, left, mid, leftChild(p), k);
}
if (updateR > mid) {
//更新的区间部分覆盖右儿子区间
update(updateL, updateR, mid + 1, childChild(p), k);
}
//重新维护父亲节点
push_up(p);
}
区间查询
区间查询就是对指定的区间进行查询,如果指定的区间完全覆盖了当前父亲节点的区间,就可以直接返回父亲节点的信息,避免进一步的查询。而如果查询区间部分覆盖了当前父亲节点,那么我们需要查询的就是子节点信息,需要把父亲节点的 lazy tag
进行下放,更新子节点的信息。然后进行子节点查询
int query(int queryLeft, int queryRight, int p, int left, int right) {
int res = 0;
if (queryLeft <= left && queryRight >= right) {
return ans[p];
}
int mid = (left + mid) / 2;
push_down(p, left, right);
if (queryLeft <= mid)
//需要查询左儿子节点
res += query(queryLeft, queryRight, leftChild(p), left, mid);
if (queryRight > mid)
//需要查询右儿子节点
res += query(queryLeft, queryRight, rightChild(p), mid+1, right);
return res;
}
以上就是树状数组和线段树的两个操作,他们都可以在 \(NlogN\)