基础树形数据结构
0. 前言
某个MXY问我为什么要讲树形数据结构。原因就是因为它复杂码量大可以装逼,还可以出一点毒瘤题,最重要的是我第一个学的难的知识就是这个能对于修改和查询的优化。
下面是四个典型数据结构时间复杂度的比较↓
数组 | 前缀和 | 线段树 | ||
---|---|---|---|---|
单点修改 | \(O(1)\) | \(O(n)\) | \(O(\log n)\) | \(O(\log n)\) |
区间和查询 | \(O(n)\) | \(O(1)\) | \(O(\log n)\) | \(O(\log n)\) |
1. 二叉树
这篇专门来讲树形数据结构,先从二叉树开始讲起。首先先来联想一下我们见到过的树,它一般有一个树干,在上面就会分成许多的树杈,树杈又会分成若干个树杈,直到最后的叶子为止。(例如下图↓)
这是我们学校外面的一棵完美二叉树(完美二叉树指的是在所有节点中,要么有两个子节点,要么没有子节点)。
树结构的特点是不仅能够表示数据之间的指向关系,还能表现出它们的层次关系,而且有很明显的递归性质。因此,可以利用这个性质去解决更多种类的问题。
先来看例题Luogu P5076 普通二叉树(简化版)。
【深基16.例7】普通二叉树(简化版)
题目描述
您需要写一种数据结构,来维护一些数( 都是 \(10^9\) 以内的数字)的集合,最开始时集合是空的。其中需要提供以下操作,操作次数 \(q\) 不超过 \(10^4\):
- 查询 \(x\) 数的排名(排名定义为比当前数小的数的个数 \(+1\)。若有多个相同的数,应输出最小的排名)。
- 查询排名为 \(x\) 的数。
- 求 \(x\) 的前驱(前驱定义为小于 \(x\),且最大的数)。若未找到则输出 \(-2147483647\)。
- 求 \(x\) 的后继(后继定义为大于 \(x\),且最小的数)。若未找到则输出 \(2147483647\)。
- 插入一个数 \(x\)。
思路
这道题的确可以使用暴力枚举的方式但是不够优雅,但是也可以使用二叉搜索树来完成
二叉搜索树具有以下性质:
- 若节点 \(x\) 的左子树不空,则 \(x\) 左子树中所有的节点的值均小于节点 \(x\) 的值。
- 若节点 \(x\) 的右子树不空,则 \(x\) 右子树中所有节点的值均大于节点 \(x\) 的值。
- 任意节点的左、右子树也分别是二叉搜索树。
- 没有键值相等的点
实现过程
首先对每个节点定义5个变量,用 \(lft\)(即 left)表示左儿子,用 \(rgt\)(即 right)表示右儿子, \(value\) 表示该节点的权值,\(siz\) (即 size)表示以该节点为根节点的子树的节点个数, \(num\) 表示该节点权值出现的次数。
- 查询 \(x\) 数的排名。每次将 \(x\) 和根节点 $ root$ 的权值进行比较。如果 \(x\) 小于根节点 \(root\) 的权值,那么 \(root\) 的右子树里的所有权值都要比 \(x\) 要大,所以要递归下去查询左子树。如果 \(x\) 大于根节点 \(root\) 的权值,那么 \(root\) 的左子树里面的所有权值都要比 \(x\) 要小,所以递归下去查询右子树,并且把左子树的大小加入答案里面。如果 \(x\) 等于根节点 \(root\) 的权值,那么我们已经找到了 \(x\) ,返回答案即可。
- 查询排名为 \(x\) 的数。当查询 \(root\) 子树的第 \(x\) 小的值时:如果 \(x \leq root\) 左子树的大小,则排名为 \(x\) 的数一定在 \(root\) 的左子树里,递归下去查询 \(root\) 的左子树中排名为 \(x\) 的数;如果 \(x\) 等于 \(root\) 左子树的大小+1,由于左子树里面的权值都小于根节点 \(root\) 的权值,右子树里面的权值都大于根节点 \(root\) 的权值,所以排名为 \(x\) 的数一定是根节点 \(root\) 的权值,将其返回即可;如果 \(x > root\) 左子树的大小+1,则排名为 \(x\) 的数一定在 \(root\) 的右子树中递归下去查询 \(root\) 的右子树中排名为 \(x\) - 左子树大小 -1的数。
- 求他的前驱后继。不用专门写两个函数来查询前驱后继,可以先查询 \(x\) 的排名 \(rnk\) (即 rank),然后查询排名为 \(rnk-1\) 的数与排名为 \(rnk(x+1)\) 的数,这两个查询结果即分别为 \(x\) 的前驱和后继。
- 插入一个数 \(x\) 。每次将 \(x\) 和根节点 \(root\) 的权值进行比较。如果 \(x <\) 根节点 \(root\) 的权值,那么把 \(x\) 插入到 \(root\) 的左儿子里面;相反则插入到 \(root\) 的右儿子里面。如果此时将 \(x\) 插入的那个位置的节点并不存在,比如要将 \(x\) 插入 \(root\) 的左子树里,但是它的左儿子是空的,则新建一个节点,权值为 \(x\) ,来代替那个不存在的节点,然后回溯的时候更新该节点的 \(siz\) 值。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e5+10;
int n, rt, cnt, opt, x;
struct Node{
int lft, rgt, siz, value, num;
Node(int l,int r,int s,int v) : lft(l), rgt(r), siz(s), value(v), num(1){}
Node(){}
}t[maxn];
inline void update(int rt/*这里的rt就是root,蒟蒻懒,简写*/){
t[rt].siz = t[t[rt].lft].siz + t[t[rt].rgt].siz + t[rt].num;
//这步是更新节点
}
inline int rnk(int x,int rt){
if(rt){
if(x < t[rt].value) //进入左子树
return rnk(x, t[rt].lft);
if(x > t[rt].value) //进入右子树并加上左子树的siz
return rnk(x, t[rt].rgt) + t[t[rt].lft].siz + t[rt].num;
return t[t[rt].lft].siz + t[rt].num;
}
return 1;
}
inline int kth(int x,int rt){ //查询排名为x的树
if(x <= t[t[rt].lft].siz) //进入左子树
return kth(x, t[rt].lft);
if(x <= t[t[rt].lft].siz + t[rt].num)
return t[rt].value;
return kth(x - t[t[rt].lft].siz - t[rt].num, t[rt].rgt);
}
inline void insert(int x,int &rt){
if(x < t[rt].value) //插入左子树
if(!t[rt].lft) //如果左儿子不存在
t[t[rt].lft = ++cnt] = Node(0, 0, 1, x);
else //有那就直接递归插进去
insert(x, t[rt].lft);
else if(x > t[rt].value) //插入右子树,底下的操作和上面的类似
if(!t[rt].rgt)
t[t[rt].rgt = ++cnt] = Node(0, 0, 1, x);
else
insert(x, t[rt].rgt);
else //如果存在,节点大小+1
t[rt].num++;
update(rt);
}
int main(){
scanf("%d",&n);
int num = 0;
t[rt = ++cnt] = Node(0, 0, 1, INT_MAX);
while(n--){
scanf("%d%d",&opt,&x);
++num;
if(opt == 1) printf("%d\n", rnk(x, rt));
else if(opt == 2) printf("%d\n", kth(x, rt));
else if(opt == 3) printf("%d\n", kth(rnk(x, rt)-1, rt));
else if(opt == 4) printf("%d\n", kth(rnk(x+1, rt), rt));
else num--, insert(x, rt);
}
return 0;
}
那么这题就完成力。
2. 傻子社长 树状数组
树状数组是一种用来维护 \(n\) 个数的前缀和信息的数据结构,虽然它和其他的树形数据结构不是特别一样,但是他有个非常美丽的优点:码量小,便于 debug。至于前缀和,这里不过多赘述。
显然,对于长度为 \(n\) 的数列,前缀和需要用长度为 \(n\) 的数组进行储存。而当数列 \(a\) 发生变化时,要使得 \(b\) 数组中的内容仍然能够正确对应数列 \(a\) 的前缀和,就需要对 \(b\) 的值进行修改,即使数列中只有一个数发生变化,也可能需要修改 \(b\) 数组多个值,才能保证整个数组仍然储存的是数列 \(a\) 的前缀和。
与此类似,对于长度为 \(n\) 的数列,树状数组也会使用长度为 \(n\) 的数组进行储存,但是它储存的内容有点复杂。首先看一个例子:
【模板】树状数组 1
题目描述
如题,已知一个数列,你需要进行下面两种操作:
-
将某一个数加上 \(x\)
-
求出某区间每一个数的和
思路
如果使用前缀和数组 \(b\),求出给定区间的和;但是修改的最坏情况又要遍历整个数组,还是会TLE。
树状数组就能结合它们两个的优势。
我们可以把任意一种数据结构抽象成黑匣子:黑匣子中储存的是数据,可以向其提供操作,包括修改和查询。能否解决问题取决于这个黑匣子是否能,以及能够以何种复杂度去实现这些操作;而我们要去实现这样的黑匣子。至于这段里写的树状数组的复杂度,请看前言部分。
实现过程
与前缀和类似,树状数组每个位置保存的也是原数组中某一段区间的和。为了准确说明每一个位置分别保存的哪一段区间,我们使用的是 \(\operatorname{lowbit}()\) 函数。至于它是个什么,自己去了解,我懒得说 \(\operatorname{lowbit}()\) 的值是 \(x\) 的二进制表达值中最低位的 \(1\) 所对应的值。
那么,假设树状数组使用数组 \(c\) 来储存,原来的 \(n\) 个数字分别为 \(a_1\) 到 \(a_n\),则 $c_i = \sum_{j = i-\operatorname{lowbit}(i)+1} ^ i {a_j} $。结论是,树状数组每个位置保存的是其向前 \(\operatorname{lowbit}()\) 长度的区间和。
这样做的好处是:假设要求 \(a_1\) 到 \(a_i\) 的和 \(s_i\),可以先将 \(c_i\) 加入答案,那么剩下的部分就是 \(a_1\) 到 \(a_{i-\operatorname{lowbit}(i)}\),现在的问题就是求 \(s_{i-\operatorname{lowbit}(i)}\)。那么接下来就将 \(c_{i-\operatorname{lowbit}(i)}\) 加入答案,不断重复操作,直到问题变成求 \(s_0\) 为止,那么就得到了 \(s_i\) 了。下面是代码:
inline int sum(int x){
int ans = 0;
for(int i = x;i;i -= lowbit(i)) ans += c[i];
return ans;
}
这个过程的每一步中,把一个数 \(x\) 变成 \(x-\operatorname{lowbit}(x)\),每次复杂度为 \(O(\log n)\)。
接下来是单点修改。假设修改的是 \(a_i\),由于可能有多个位置对应的区间包含 \(a_i\),对于这些位置都要修改。得到需要修改的位置的代码如下:
inline void add(int v,int w){
for(int i = v;i <= n;i += lowbit(i)) c[i] += w;
}
由于 \(\operatorname{lowbit}\) 的值只有不超过 \(\log(n)\) 种,一次修改中一个 \(\operatorname{lowbit}\) 的值最多只会对应一个需要修改的位置,所以每一次修改的时间复杂度也为 \(\log(n)\)。
于是,我们的整个思路就已经有了,代码如下:
#include<bits/stdc++.h>
using namespace std;
const int maxn = 5e5+10;
int n, m;
int a[maxn], c[maxn];
inline int lowbit(int x){return x & (-x);}
inline int sum(int x){
int ans = 0;
for(int i = x;i;i -= lowbit(i)) ans += c[i];
return ans;
}
inline void add(int v,int w){
for(int i = v;i <= n;i += lowbit(i)) c[i] += w;
}
int main(){
scanf("%d%d",&n,&m);
for(int i = 1;i <= n;++i){
scanf("%d",&a[i]);
add(i, a[i]);
}
while(m--){
int opt, x, y;
scanf("%d%d%d",&opt,&x,&y);
if(opt == 1) add(x,y);
else printf("%d\n",sum(y) - sum(x-1));
}
return 0;
}
那么这道题就完成了。虽然推导过程略微繁琐,但是树状数组的代码很简短。这里再提供一个例题,其中 \(\operatorname{add()}\) 与 \(\operatorname{sum()}\) 函数都是一样的。
3. 线段树
前言
这是我第一个学的难点,并且我也对此很感兴趣,所以这个我会详细讲。美丽的SegmentTree。
线段树介绍
线段树是算法竞赛中常用的用来维护 区间信息 的数据结构。
线段树是一颗完满二叉树(完满二叉树和前文中的完美二叉树是不一样的,具体区别请看这里),每个节点都代表数组上的一个区间。根节点维护的是整个区间,叶节点维护每个单点,节点的左儿子和右儿子分别维护节点对应区间的左半部分和右半部分。线段树将每个长度不为 \(1\) 的区间划分成左右两个区间递归求解,把整个线段划分为一个树形结构,通过合并左右两区间信息来求得该区间的信息。这种数据结构可以方便的进行大部分的区间操作。
线段树可以在 \(O(\log n)\) 的时间复杂度内实现单点修改、区间修改、区间查询(区间求和,求区间最大值,求区间最小值)等操作。
下面来看一道线段树的例题。Luogu P3372 【模板】 线段树 1
P3372 【模板】 线段树 1
如题,已知一个数列,你需要进行下面两种操作:
- 将某区间每一个数加上 \(k\)。
- 求出某区间每一个数的和。
思路
如果直接使用数组直接储存和维护这个数列,虽然修改的效率是 \(O(\log 1)\) 但是求和的效率很低,是 \(O(\log n)\) 了。这时候我们就用一种能够维护序列的树形数据结构—— 线段树。
线段树的思想是在于将序列中的若干个区间在树上用节点表示,其中 \(\left[{1,n}\right]\) 的区间(\(n\) 是序列长度)是树的根。而对于一个表示区间 \(\left[{l,r}\right]\) 的节点(\(l \neq r\)),设 \(mid = \left\lfloor\dfrac{l+r}{2}\right\rfloor\),将 \(\left[{l,mid}\right]\) 和 \(\left[{mid+1,r}\right]\) 作为该节点的左子节点和右子节点。
线段树有如下三条性质:
- 对于线段树上的任意一个节点,要么没有子节点,要么有一个子节点,不存在只有一个节点的情况(即前文说的完满二叉树)。
- 对于一个长度为 \(n\) 的序列,它所建立的线段树只有 \((2n-1)\) 个节点。
- 对于一个长度为 \(n\) 的序列,它所建立的线段树高为 \(\log n\)。
实现过程
- 建立线段树。
树是递归定义的,因此可以用递归的方式建树,方法:如果这个区间里的左端点等于右端点,说明是叶子结点,其数据的值复制危对应数列的元素的值;否则将这个区间分为两部分,再继续递归建树,最后进行总汇总,即 \(\operatorname{pushUp()}\)。
下面是建立线段树的代码:
long long a[maxn], w[maxn<<2];
inline void pushUp(const int root){
w[root] = w[root<<1] + w[root<<1|1]; //w[u]是区间和,u<<1是左子树,u<<1|1是右子树;"<<" 是位移运算,"<<" 表示在二进制表示中把整体向左移一位,<<n = * $2^n$。"|" 是按位或运算,<<1|1 = *2+1
}
inline void BuildTree(const int root,int lft,int rgt){
if(lft == rgt){ /*叶子结点*/
w[root] = a[lft];
return;
}
int mid = (lft + rgt)>>1; //将区间分成[lft,mid]和[mid+1,rgt]两部分;这里的 ">>" 也是位移运算符,表示在二进制中把整体向右移一位,>>1 = /2
buildTree(root<<1, lft, mid);
buildTree(root<<1, mid+1, rgt); //递归构造树
pushUp(root); //由子区间的区间和更新当前区间的和
}
/*
对于上面代码的备注:
1. pushUp() 的时间复杂度是 O(n)。
2. 位移运算在低版本编译器中能够有一定的优化。
*/
- 单点查询和修改。
精确到某个叶子结点的方法可以等价为找到 \(\left[{p,p}\right]\) 这个区间。这里查询与修改操作的递归思想和前面差不多,只需要判断在哪一个区间里然后递归下去就行了,代码如下:
inline long long query1(int root,int lft,int rgt,int p){
if(lft == rgt) return w[root]; //到达叶子结点返回
else{
int mid = (lft + rgt)>>1;
if(mid >= p) //是否在左子树内
return query1(root<<1, lft, mid, p); //是的话递归查询
else //否则就是右子树
return query1(root<<1|1, mid+1, rgt, p); //递归下去
}
}
inline void update(int root,int lft,int rgt,int p,long long x){
if(lft == rgt) w[root] = x; //到达叶子结点赋值
else{
int mid = (lft + rgt)>>1;
if(mid >= p)
update1(root<<1, lft, mid, p, x);
else
update1(root<<1|1, mid+1, rgt, p, x);
pushUp(root);
} //和 query 的思想一样,但是别忘了更新后修改结点和
}
/*
对于上面代码的备注:
1. 因为树高是 O(log n),所以递归也只有 O(log n) 次。因此,线段树的单点操作的复杂度为 O(log n)。
*/
- 区间查询。
根据题目,给定一个区间 \(\left[{l,r}\right]\),求这个区间的数字和。
很简单,还是递归,从根开始,如果当前节点的区间 \(\left[{lft,rgt}\right]\) 被 \(\left[{l,r}\right]\) 包含,直接返回当前区间的区间和;如果没有交集,返回0;如果没被包含且两区间有交,那么递归左右子节点处理。代码如下:
inline bool InRange(int lft,int rgt,int l,int r){ //判断是否在区间里
return (l <= lft) && (rgt <= r);
}
inline bool OutofRange(int lft,int rgt,int l,int r){ //判断是否无交
return (lft > r) || (rgt < l);
}
inline long long query(int root,int lft,int rgt,int l,int r){
if(InRange(lft, rgt, l, r)){ //完全包含
return w[root]; //返回区间和
}else if(!OutofRange(lft, rgt, l, r)){ //包含一部分
int mid = (lft + rgt)>> 1;
return query(root<<1, lft, mid, l, r) + query(root<<1|1, mid+1, rgt, l, r); //递归处理
}else return 0; //完全无交
}
- 区间修改。(最重要的一部分)
在区间修改时,不能暴力地把每个叶子改了(否则学线段树有啥意义),这样效率很低。为了解决这个问题,我们引入了 懒惰标记 (又叫做延迟标记,通常用 \(lazyTag\) 表示),记录区间修改的信息。当递归到一个被完全包含的区间时,在这个区间搭上一个懒惰标记,记录这个区间中的每一个数都要被加上某一个数,然后直接修改节点,不需要向下递归。当新访问到一个节点时,下放懒惰标签,再递归。复杂度保证为 \(O(\log n)\)。对于打了懒惰标签的点,维护的区间和是已经修改完成的值,其子节点的值还没有被修改。换言之,懒惰标签的作用是记录子节点的每个数应该加上多少。代码如下:(别忘了下放,蒟蒻之前一道题忘记下放,调了 \(10\) 分钟甚至 \(9\) 分钟)
long long lazyTag[maxn<<2];
inline void makeTag(int root,int len,long long x){
lazyTag[root] += x; //修改当前节点的懒惰标签
w[root] += len * x; //修改当前节点的区间和
}
inline void pushDown(int root,int lft,int rgt){
int mid = (lft + rgt)>>1;
makeTag(root<<1, mid-lft+1, lazyTag[root]); //左子树加上 lazyTag[root]
makeTag(root<<1|1, rgt-mid, lazyTag[root]); //左子树加上 lazyTag[root]
lazyTag[root] = 0; //清空懒惰标记
}
inline long long query(int u,int lft,int rgt,int l,int r){
if(InRange(lft, rgt, l, r)) return w[root]; //返回区间和
else if(!OutofRange(lft, rgt, l, r)){
int mid = (lft + rgt)>>1;
pushDown(root, lft, rgt); //下放懒惰标记
return query(root<<1, lft, mid, l, r) + query(root<<1|1, mid+1, rgt, l, r);
}else return 0; //无交
}
inline void update(int root,int lft,int rgt,int l,int r,long long x){
if(InRange(lft, rgt, l, r)){
makeTag(root, rgt-lft+1, x); //直接打懒惰标记
}else if(!OutofRange(lft, rgt, l, r)){
int mid = (lft + rgt)>>1;
pushDown(root, lft, rgt); //将节点的懒惰标记下放,再进行递归修改
update(root<<1, lft, mid, l, r, x);
update(root<<1|1, mid+1, rgt, l, r, x);
pushUp(root);
}
}
/*
对于上面代码的备注:
1. pushDown() 是把懒惰标签下放的过程,makeTag() 函数是更新懒惰标签的过程。lazyTag 数组记录的是当前节点加的值的大小,然后该区间和与要增加的值就是长度×增加量。
2. 正如注释所说,在下放懒惰标记后,应当先清空当前的懒惰标记,并且要判断目标区间是否在当前时间内再 pushDown(),否则等待你的是数组越界。
*/
接下来是简单的主函数代码,很简单,如下:
int main(){
int n, m;
scanf("%d%d",&n,&m);
for(int i = 1;i <= n;++i) scanf("%lld",&a[i]);
buildTree(1, 1, n);
for(int t = 1;t <= m;++t){
int opt, x, y;
long long k;
scanf("%d",&opt);
if(opt == 1){
scanf("%d%d%lld",&x,&y,&k);
update(1, 1, n, x, y, k);
}else{
scanf("%d%d",&x,&y);
printf("%lld\n",query(1, 1, n, x, y));
}
}
return 0;
}
至此,我们线段树的例题就结束了,篇幅很长 同时背起来也很难。
再给出两道例题来训练,这里就不给出提示了
4. 结束语
看到这里,表明你现在已经学会了 CSP-S 中常用的树形数据结构了。恭喜你!
写完了基础篇,接下来我会去写提高篇,但是提高篇里只有平衡树和可持久化线段树是 CSP-S 会考的。提高篇应该会很难,毕竟有些连 NOI 都不考,写的目的就是玩玩。
我的下一篇 进阶篇树形数据结构
标签:rt,rgt,int,基础,树形,区间,数据结构,root,节点 From: https://www.cnblogs.com/fy123333/p/17593765.html