2104:K-th Number:以题为例,一文搞懂主席树的原理+代码(我画了一天图,就是为了你能看懂!)

题目大意

题目链接
给定数组 a [ 1... n ] a[1...n] a[1...n],对每一个查询 Q ( i , j , k ) Q(i,j,k) Q(i,j,k),求出 [ i , j ] [i,j] [i,j]区间中第 k k k大的数字。

思路分析

最简单的方法无非对所求的区间 [ i , j ] [i,j] [i,j],我们对这个来一次排序,然后求第 k k k个,但是这样的话每次操作都是 O ( n log ⁡ n ) O(n\log n) O(nlogn),而且操作数目 m < 5000 m<5000 m<5000。对于区间查询的问题,想要高效的解决,最好的方法无非线段树,但是对于第k大的值,如何通过线段树来解决呢?

主席树简介

主席树又称函数式线段树,顾名思义,也就是通过函数来实现的线段树,至于为什么叫主席树,那是因为是fotile主席创建出来的这个数据结构(其实貌似是当初主席不会划分树而自己想出来的另一个处理方式。。。。是不是很吊呢? ORZ…)不扯了,切入正题。

主席树就是利用函数式编程的思想来使线段树支持询问历史版本、同时充分利用它们之间的共同数据来减少时间和空间消耗的增强版的线段树。

发明者的原话:“对于原序列的每一个前缀[1···i]建立出一棵线段树维护值域上每个数出现的次数,则其树是可减的”

可以加减的理由:主席树的每个节点保存的是一颗线段树,维护的区间信息,结构相同,因此具有可加减性(关键

首先开一个数组t[n],存储内容为a中排序并去重的值(类似于离散化),每棵线段树维护的内容是a1…ai此区间中的树在t[n]中出现的次数

举个栗子

an:4 1 1 2 8 9 4 4 3

将序列排序并去重后得到t[n]:

tn:1 2 3 4 8 9

对前缀a[1…9]建树,1*2,2*1,3*1,4*3,8*1,9*1,每个数出现的次数即为线段树维护的值,树中每个节点表示t[i,j],中的数字在a[1…9]中出现的次数

建树:线段树的每个节点是离散后的数的编号(因此节点区间最多[1,6]),对前缀a[1…9],我们对每一个位置 1 ≤ i ≤ 9 1\leq i\leq 9 1i9创建一颗线段树,对原数组的 [ 1 , i ] [1,i] [1,i]区间的数做统计以下以1位置和9位置的线段树为例,我们分别记为 t r e e 1 , t r e e 9 tree_1,tree_9 tree1,tree9

建树实例

初始时所有位置元素都一样,都是0
在这里插入图片描述
然后我们分别插入a数组的每一个元素,

插入4:
在这里插入图片描述
插入:1
在这里插入图片描述
这里就需要注意了,因为tree1只是 a [ 1 ] − a [ 1 ] a[1]-a[1] a[1]a[1]的树,统计该范围下出现在t[n]各个区间内元素的个数,因此第2,3,…,9元素tree1就不会更新了,相反tree9是为前九个元素创建的树,因此所有元素都会更新tree9的值,最后更新为:
在这里插入图片描述

查询实例

我们先画上面这三个树,有了这三个树,我们先考虑以下如何求a[1…9]的第k小的值,注意由于我们的t[n]数组是排过序的,所以对这些树而言,左侧元素肯定小。所以如果左侧有超过5个元素,那我们直接来到左子树进行寻找,否则我们转向右子树去找第 k − l k-l kl小的值(假设左字树有 l l l个元素)。假设 k = 5 k=5 k=5,那我变成了在右子树找最小的值。同样的,节点 [ 4 , 6 ] [4,6] [4,6]左子树大于1,我们搜索左子树,而 [ 4 , 5 ] [4,5] [4,5]左子树也大于1,我们继续搜索左子树,然后到了 [ 4 , 4 ] [4,4] [4,4]叶子节点,于是乎我们得到答案是 t [ 4 ] = 4 t[4]=4 t[4]=4

对于单个 [ 1 , i ] [1,i] [1,i]的所有区间我们都可以这样求,因为 [ 1 , i ] [1,i] [1,i]我们都有一颗对应的树。那么对于 [ i , j ] [i,j] [i,j]呢?我们不妨考虑一下如何计算a[6…9]之间第2小的值。因为从a[6]开始,所以前5个元素是不能用的,而前五个元素的信息存储在 T r e e 5 Tree_5 Tree5中,我们只需要 T r e e 9 − T r e e 5 Tree_9-Tree_5 Tree9Tree5就得到了 a [ 6...9 ] a[6...9] a[6...9]对应的树。(所谓树的减法,其实就是每个节点的元素相减)。
a [ 6 , . . . 9 ] : 9443 a[6,...9]:9 4 4 3 a[6,...9]:9443
我们得到下图:
在这里插入图片描述
你可以把元素一次插入建树,结果和相减是一致的,这是主席数的一个重要性质。然后重复上述的过程就能求解第k小的值。

存储问题

但是a数组的元素有很多我们不可能每个都维护一个完整的树,怎么解?其实可以看到,上面的树他们的结构是完全相同的,而且树与树之间的差距并不大,我们可以考虑只维护不同的部分来节省存储。观察 T r e e 8 , T r e e 9 Tree_8,Tree_9 Tree8,Tree9我们插入了一个元素3,所以变化的节点只是小部分
在这里插入图片描述
所以对于不变的元素,我们在建树时不做创建,而是直接连接到 T r e e 8 Tree_8 Tree8对应的子树或者节点,这样我们只需要增加3个新节点,就维护了一个完整的子树。
在这里插入图片描述
考虑前四个元素的完整主席树,大概长成下面这样
在这里插入图片描述

代码剖析

预定义元素+建树

#define MAX 200000
#define ll long long

ll t[MAX << 5], lc[MAX << 5], rc[MAX << 5];//主席树相关,一般开MAX的32倍
ll a[MAX], b[MAX];//原序列和离散排序序列
ll T[MAX]; T[i]为插入i个点的树的根节点编号    
ll nodeNum;//节点数目
ll build(ll l, ll r) {//建一个空树(所有sum[i]都为0) 
	ll num = ++nodeNum;//num为当前节点编号 
	if (l != r) {
		ll mid = (l + r) >> 1;
		lc[num] = build(l, mid);
		rc[num] = build(mid + 1, r);
	}
	return num;//返回当前节点编号
}

更新操作

//pre为旧树该位置节点的编号,l,r:区间端点,x:要插入的位置
inline ll update(ll pre, ll l, ll r, ll x) {
	int num = ++nodeNum;//新建一个节点
	lc[num] = lc[pre], rc[num] = rc[pre], t[num] = t[pre] + 1;
	//该节点左右儿子初始化为旧树该位置节点的左右儿子
	//因为插入的a[i]在该节点所代表的区间中 所以sum++ 
	if (l != r) {
		ll mid = (l + r) >> 1;
		//x出现在左子树 因此右子树保持与旧树相同 修改左子树 
		if (x <= mid)lc[num] = update(lc[pre], l, mid, x);
		else rc[num] = update(rc[pre], mid + 1, r, x);
	}
	return num;
}

该操作其实就是下图,每次新插入一个元素 a [ i ] a[i] a[i],我们要调用 u p d a t e ( ) update() update()来建一棵树,pre是上一颗树的根节点,x是 a [ i ] a[i] a[i]在离散化数组中的位置,在下面3的位置恰好就是3号元素 t n [ 3 ] = 3 tn[3]=3 tn[3]=3

an:4 1 1 2 8 9 4 4 3

将序列排序并去重后得到t[n]:

tn:1 2 3 4 8 9

我们先创建一个根节点将左右孩子都指向 T r e e 8 Tree_8 Tree8的左右孩子,因为新加入一个元素,所以根节点的数目+1。然后递归向下寻找,因为3号位置在左子树,所以右子树的指针保持不变,update左子树,同样的,我们创建一个新点作为 T r e e 9 Tree_9 Tree9的左子树,并将子树的指针指向 T r e e 8 Tree_8 Tree8的相应位置的左右孩子,然后我们发现3号位置在右子树,所以 [ 1 , 3 ] , 4 [1,3],4 [1,3],4的左孩子不变,更新右孩子。。。每次更新完返回这个孩子的节点编号,用以更新父亲节点的lc,rc属性。
在这里插入图片描述

查询操作

//u,v:待查询的区间左端点对应子树的根节点,右端点对应子树的根节点。 l,r:当前的区间端点 ,k:第k小
inline ll query(ll u, ll v, ll l, ll r, ll k) {
	if (l == r)return b[l];
	ll mid = (l + r) >> 1, num = t[lc[v]] - t[lc[u]];
	//num:v的左子树元素个数-u的左子树的元素个数,和我们之前分析的一样
	if (num >= k)return query(lc[u], lc[v], l, mid, k);
	//当 左儿子数字出现的次数大于等于k 时 意味着 第k小的数字在左子树处 
	else return query(rc[u], rc[v], mid + 1, r, k - num);
	//否则去右子树处找第k-num小的数字 
}

离散化+各个子树的建立

int main() {
	ll n, m; cin >> n >> m;
	for (ll i = 1; i <= n; i++) { cin >> a[i]; b[i] = a[i]; }//a,b两个都要保留
	sort(b + 1, b + n + 1);//先排序后unique
	ll cnt = unique(b + 1, b + n + 1) - b - 1;//离散化处理,求出不同元素的个数
	T[0] = build(1, cnt);//初始化 建立一颗空树 并把该树的根节点的编号赋值给T[0]
	for (ll i = 0; i < n; i++) {
		ll tmp = lower_bound(b + 1, b + 1 + n, a[i]) - b;
		//在b的[1,size+1] 中二分查找第一个等于a[i]的b[x]
		T[i + 1] = update(T[i], 1, cnt, tmp);
		//更新a[i]带来的影响,可以看到这里就是每个a[i]都建立了一棵树
		//并将新树的根节点的编号赋值给T[i] 
	}
	while (m--) {
		ll q, w, e; cin >> q >> w >> e;
		cout << query(T[q - 1], T[w], 1, cnt, e) << endl;
	}
}

完整代码:

#include<iostream>
#include<map>
#include<string>
#include<string.h>
#include<queue>
#include<algorithm>
using namespace std;

#define MAX 200000
#define ll int

ll t[MAX << 5], lc[MAX << 5], rc[MAX << 5];//主席树相关,一般开MAX的32倍
ll a[MAX], b[MAX];//原序列和离散排序序列
ll T[MAX]; T[i]为插入i个点的树的根节点编号    
ll nodeNum;//节点数目

ll build(ll l, ll r) {//建一个空树(所有sum[i]都为0) 
	ll num = ++nodeNum;//num为当前节点编号 
	if (l != r) {
		ll mid = (l + r) >> 1;
		lc[num] = build(l, mid);
		rc[num] = build(mid + 1, r);
	}
	return num;//返回当前节点编号
}

//pre为旧树该位置节点的编号,l,r:区间端点,x:要插入的位置
inline ll update(ll pre, ll l, ll r, ll x) {
	int num = ++nodeNum;//新建一个节点
	lc[num] = lc[pre], rc[num] = rc[pre], t[num] = t[pre] + 1;
	//该节点左右儿子初始化为旧树该位置节点的左右儿子
	//因为插入的a[i]在该节点所代表的区间中 所以sum++ 
	if (l != r) {
		ll mid = (l + r) >> 1;
		//x出现在左子树 因此右子树保持与旧树相同 修改左子树 
		if (x <= mid)lc[num] = update(lc[pre], l, mid, x);
		else rc[num] = update(rc[pre], mid + 1, r, x);
	}
	return num;
}

//u,v:待查询的区间左端点对应子树的根节点,右端点对应子树的根节点。 l,r:当前的区间端点 ,k:第k小
inline ll query(ll u, ll v, ll l, ll r, ll k) {
	if (l == r)return b[l];
	ll mid = (l + r) >> 1, num = t[lc[v]] - t[lc[u]];
	//num:v的左子树元素个数-u的左子树的元素个数,和我们之前分析的一样
	if (num >= k)return query(lc[u], lc[v], l, mid, k);
	//当 左儿子数字出现的次数大于等于k 时 意味着 第k小的数字在左子树处 
	else return query(rc[u], rc[v], mid + 1, r, k - num);
	//否则去右子树处找第k-num小的数字 
}

int main() {
	ll n, m; scanf_s("%d%d", &n, &m);
	for (ll i = 1; i <= n; i++) { scanf_s("%d", &a[i]); b[i] = a[i]; }//a,b两个都要保留
	sort(b + 1, b + n + 1);
	ll cnt = unique(b + 1, b + n + 1) - b - 1;//离散化处理,求出不同元素的个数
	T[0] = build(1, cnt);//初始化 建立一颗空树 并把该树的根节点的编号赋值给T[0]
	for (ll i = 1; i <= n; i++) {
		ll tmp = lower_bound(b + 1, b + 1 + cnt, a[i]) - b;
		//在b的[1,size+1] 中二分查找第一个等于a[i]的b[x]
		T[i] = update(T[i - 1], 1, cnt, tmp);
		//更新a[i]带来的影响,可以看到这里就是每个a[i]都建立了一棵树
		//并将新树的根节点的编号赋值给T[i] 
	}
	while (m--) {
		ll q, w, e; cin >> q >> w >> e;
		printf("%d\n", query(T[q - 1], T[w], 1, cnt, e));
	}
}
  • 6
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值