学习主席树,在网上搜了很多教程(都好简短啊,直接就是几行字就上代码,看不懂啊有木有~~),最后才很艰难的学会了最基础的部分。下面就是我在学习的过程中的产生的疑惑和解决的办法。
学习主席树需要的前置技能:线段树。
1. B站上的视频讲解(话说B站真的啥都有啊)
https://www.bilibili.com/video/av4619406/?p=1
2.参考博客
https://blog.csdn.net/metalseed/article/details/8045038
话说主席树这个名字的来历还是挺好玩的 ,发明它的人叫黄嘉泰,名字的首字母呢就是(HJT),和我们的某位主席的名字简写是一样的,所以就有了这个名字。
言归正传,那么,主席树到底是啥,主席树是一种可持久化数据结构(可持久化线段树)(也就是可以查询历史版本的线段树),也叫函数式线段树。具体点说就是你对当前状态的线段树进行了很多次更新,然后在后面的过程中,你还可以找到这个更新之前的版本的线段树。再说通俗点就是每一次更新,都会把旧的线段树存起来,这样以后用的时候就可以直接找啦。显然直接创建一堆线段树是显然会爆内存的。但是呢我们发现,如果更新了一个点,那么只有一条线上的节点会被更新,也就是说,我们记录下一个版本的线段树的时候,可以共用前一个版本线段树的大部分节点,这样就可以节省内存啦。(巧妙啊)(所以也可以说主席树是一些相互之间联系密切的线段树的集合)
如下图,修改红圈内的点,只会影响这一条线上的点。(因为线段树每一个节点所维护的信息都只是由左右子树的信息合并得到的)
对于一般的线段树来说,如果父节点的编号是 i ,那么他的两个子节点的编号分别为 2 * i(左), 2 * i + 1(右),但是主席树在这一点则有别于一般的线段树,每一个父节点,他的两个子节点的编号则不一定满足这个关系(因为我们上面所说的节点共用0.0)(示意图如下)。
红色的点是更新是修改的点,对于这些点,我们没有办法用以前的节点(因为是新修改的节点),所以我们只能把这些节点新建一份(蓝色的点),但是,剩余的其他节点的信息都是相同的,我们就没有必要再去创建新的节点了,直接把儿子节点的下标指向前一个版本对应的节点的下标即可(如图中的红色虚线)。
附上一个大家都用了的牛人的理解:
所谓主席树呢,就是对原来的数列[1..n]的每一个前缀[1..i](1≤i≤n)建立一棵线段树,线段树的每一个节点存某个前缀[1..i]中属于区间[L..R]的数一共有多少个(比如根节点是[1..n],一共i个数,sum[root] = i;根节点的左儿子是[1..(L+R)/2],若不大于(L+R)/2的数有x个,那么sum[root.left] = x)。若要查找[i..j]中第k大数时,设某结点x,那么x.sum[j] - x.sum[i - 1]就是[i..j]中在结点x内的数字总数。而对每一个前缀都建一棵树,会MLE,观察到每个[1..i]和[1..i-1]只有一条路是不一样的,那么其他的结点只要用回前一棵树的结点即可,时空复杂度为O(nlogn)。
上面讲过主席树是所有历史版本的线段树的集合,那么只要是需要用历史版本的线段树来解决的问题,我们都可以用主席树来解决(废话!(╯‵□′)╯︵┻━┻)。这样说是不是太笼统了(当然!(╯‵□′)╯炸弹!•••*~●)?一般比较常用的情况就是对前i个数建立一颗线段树(比如第i颗树是由前i个数的信息建立成的,那么第i+1颗树就相当于是在第i颗数的基础上,多存了一个数(第i+1个数)的信息,也就是进行了一次更新,所以可以共用第i颗树的大部分节点,这样就构成了一颗主席树),举个例子,我们可以用它来求区间的第k小(大)的值,也可以求区间内有多少种数字。主席树的题是可以非常灵活的,难点就在于灵活的建树,和如何建立利用线段树。
最经典的例题当然是求区间第k小的题了,题目链接:POJ-2104
题目大意:有n个数,m个询问。先给你n个数字,然后每个询问会告诉你一组(l,r,k),意思是询问区间【l,r】之间第k小的值。
看到这儿,我们可以先去想如果只有一个区间的话,这个问题可以怎么解决。这个时候我们的线段树就可以闪亮登场啦!我们可以对查询的区间内的所有数建立一颗权值线段树(如果有负数的话,可以整体加上最大的负数的绝对值,转化成正数,最后不要忘了转化回来就行)所谓的权值线段树就是指:线段树的区间【l,r】所维护的信息就是此区间所包含的数的个数(即大于等于 l 小于等于 r 的数的个数)。(叶子节点【l,l】记录的信息就是数字l的出现次数)。
建立好了权值线段树之后(当前权值线段树维护的信息是第L个数至第R个数的信息,也就是把需要查询的区间内的所有数都用线段树进行维护),那么我们应该怎么去查询呢。查询的时候,如果左子树所代表的区间包含数的个数大于等于k,就说明所要查询的第k小的数在左子树中,否则就在右子树中(假设左子树中一共有sum个数,因为左子树代表的区间小于右子树代表的区间,所以总体的第k小就是右子树中的第k-sum小),递归查询,最终到达叶子节点的时候,输出叶子节点即是第k小的值。
查询的代码如下(很简单):
//l,r代表维护的区间,num代表当前区间所包含的数字的个数
int Query(int k,int cnt)
{
if(tree[cnt].l==tree[cnt].r)
return tree[cnt].l;
if(tree[cnt<<1].num>=k)
return Query(k,cnt<<1);
else
return Query(k-tree[cnt<<1].num,cnt<<1|1);
}
哈,那这样的话,我们这一题的解法是不是就出来了呢?对每个区间建立一个线段树,然后按照上面的方式求解。但这样显然是不行的,我们承受不了这么大的时空复杂度。这个时候我们的主席树就上场了,“线段树,你退下吧,一切有我!”。(很多线段树?想到了什么,主席树就是很多线段树的集合啊。)
主席树的各个节点都是同一结构的线段树(相同的区间,相同的信息)(因为是保存了之前的历史信息0.0)。线段树对一条线段,保存的是这个数字区间的出现次数,所以是可以互相加减的。如果我们的主席树是每次插入一个点来更新的,那么第一个线段树也就是第一个数组成的线段树,第 i 颗线段树,就是前 i 个数组成的线段树(按照上面讲的线段树的建树方式,区间信息是包含的数的个数)。那么类比前缀和的思想,我们怎么表示区间【l,r】之间有多少个数呢?只要拿出 Tj 和 Ti-1,对每个节点相减就可以了。说的通俗一点,询问 i~j 区间中,一个数字区间(a~b)的出现次数时,就是这些数字在 Tj 中出现的次数减去在 Ti-1 中出现的次数。(注意区分区间i~j 和 a~b哦)。
有的同学会说了,这样也不行啊,你忽略了一个很重要的问题,就是数的范围!题干中给的数的范围是 -1e9~1e9,线段树怎么可能开得下啊!(因为区间的大小是因为数的大小确定的)。对,这是一个很重要的问题,但是我们发现,数的个数是1e5个,是在我们能接受的范围之内的。也就是说我们单单按照数字大小来建树的话,会浪费掉非常多的空间。那该怎么办呢??为了解决这个问题,我们要引入一个高大上的方法,叫离散化(为啥叫这个名字我也不知道)。
离散化是啥?怎么离散化?下面我就说一点自己的理解。我的理解是离散化是一种映射关系(Hash)。就拿这个题来说,我们可以把这n个数排序,然后把最小的映射成1,次小的映射成2......(注意去重)以此类推形成一种映射关系,然后我们就可以按照这种映射关系来建树,这样时空复杂度就在我们可以承受的范围内了~。
在B站上学到的一种离散化的方法如下:(个人觉得挺好的)
首先我们读入数据的时候顺便把数据压入到一个vector中
for(int i=1;i<=n;i++)
{
scanf("%d",&a[i]);
v.push_back(a[i]);
}
然后把vector排序去重(利用unique函数)
sort(v.begin(),v.end());
v.erase(unique(v.begin(),v.end()),v.end());
然后利用二分我们就可以愉快的得到映射的值辣
int getid(int x)
{
return lower_bound(v.begin(),v.end(),x)-v.begin()+1;
}
这样我们的思路就讲完了,下面是具体实现。
上代码,解释见注释!
void Update(int l,int r,int &x,int y,int pos)
//l,r代表当前区间 x代表当前更新的空树 y代表x这个数所需要共用节点的
//上一版本的树 pos代表当前更新的数
{
T[++cnt]=T[y],T[cnt].sum++,x=cnt;
//创建树的节点
if(l==r)
return;
int mid=(l+r)>>1;
//判断当前的数大小,来选择更新左子树还是右子树
if(mid>=pos)
Update(l,mid,T[x].l,T[y].l,pos);
else
Update(mid+1,r,T[x].r,T[y].r,pos);
}
建树的时候不断的进行单点更新即可
for(int i=1;i<=n;i++)
Updata(1,n,root[i],root[i-1],getid(a[i]));
//每一棵树依赖的都是他的前一棵树(也就是他的历史版本)
//root[i]存的是第i颗树的根节点的坐标
上代码,解释见注释!
int Query(int l,int r,int x,int y,int k)
//l,r代表操作区间 x代表第l颗树 y代表第r颗树 k代表所求的第k小的数中的k
{
if(l==r)
return l;
//到达叶子节点 返回答案即可
int mid=(l+r)>>1;
int sum=T[T[y].l].sum-T[T[x].l].sum;
//判断所求的数在左子树还是右子树
if(sum>=k)
return Query(l,mid,T[x].l,T[y].l,k);
else
return Query(mid+1,r,T[x].r,T[y].r,k-sum);
//注意理解从k到k-sum的变化
}
经过上面三步,大家应该已经能够实现这个代码了
#include
#include
#include
#include
using namespace std;
const int MAXN = 1e5 + 10;
struct Tree {
int l, r, sum;
} T[MAXN * 40];
vector v;
int cnt, root[MAXN], a[MAXN];
void Init() {
cnt = 0;
T[cnt].l = 0;
T[cnt].r = 0;
T[cnt].sum = 0;
root[cnt] = 0;
v.clear();
}
int getid(int x) { return lower_bound(v.begin(), v.end(), x) - v.begin() + 1; }
void Update(int l, int r, int &x, int y, int pos) {
T[++cnt] = T[y], T[cnt].sum++, x = cnt;
if (l == r) return;
int mid = (l + r) >> 1;
if (mid >= pos)
Update(l, mid, T[x].l, T[y].l, pos);
else
Update(mid + 1, r, T[x].r, T[y].r, pos);
}
int Query(int l, int r, int x, int y, int k) {
if (l == r) return l;
int mid = (l + r) >> 1;
int sum = T[T[y].l].sum - T[T[x].l].sum;
if (sum >= k)
return Query(l, mid, T[x].l, T[y].l, k);
else
return Query(mid + 1, r, T[x].r, T[y].r, k - sum);
}
int main() {
Init();
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
v.push_back(a[i]);
}
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
for (int i = 1; i <= n; i++)
Update(1, n, root[i], root[i - 1], getid(a[i]));
int l, r, k;
for (int i = 1; i <= m; i++) {
scanf("%d%d%d", &l, &r, &k);
printf("%d\n", v[Query(1, n, root[l - 1], root[r], k) - 1]);
}
return 0;
}
https://blog.csdn.net/weixin_42165981/article/details/81154209
主席树的本质就是一堆线段树的集合(也就是包含历史版本的线段树),所以需要一堆线段树来解决的问题,就可以用我们的主席树来解决了,主席树与线段树最大的区别就是主席树的左右儿子的节点编号是不固定的。那么我们在编写代码的时候,传入根节点的坐标,然后再记录左右儿子的坐标,这样我们的查询,更新函数,都和普通的线段树差不了多少,关键就是节点的公用关系,和线段树在题目中的意义和用法!
本篇学习笔记到此正式结束,以后学到了新的东西会继续更新的(主席树还有好多东西要学啊 啊啊~~~~)