第K小数
题目描述
核心思路
因为序列A的下标最大为 N = 1 0 5 N=10^5 N=105,然而最坏情况下 A [ i ] = 1 0 9 A[i]=10^9 A[i]=109,因此我们需要先对序列A进行离散化,让 A [ i ] A[i] A[i]的下标都落在0到 N − 1 N-1 N−1之间。
先将数组里面每个数进行离散化,用可持久化线段树维护每一个数值出现的次数,比如某个节点所维护区间中所含有的序列为{24,3,24,99,13,99,23,99},那么数值3出现1次,数值13出现1次,数值23出现1次,数值24出现2次,数值99出现3次,那么这个节点所维护区间中出现的数值的总个数为1+1+1+2+3=8。从左到右每增加一个数值,线段树增加一个版本,由此我们想到了可持久化线段树。对于一个区间[0, M]而言,可以求出 任何一个版本下,离散化后数值在[0, M]中的数字的总个数。题目要求第L个数到第R个数中第K小的数值是多少,可以在第L-1个版本和第R个版本分别求区间[0,M]中的数值总数,两者的差就是原来的第L个数到第R个数, 离散化后的数值落在数值区间[0, M]的总个数,如果这个个数刚好就是K,那就等价于找到了一个合法的M,用二分法查找最小的一个合法的M,再把M的数值映射回原始的数值即可。
先考虑如何求总序列第k小
我们可以建立一颗权值线段树,每个点存储的信息为该值域区间存在的数的个数。
例如该图,节点2代表的是值域为 [ 1 , 2 ] [1,2] [1,2]的区间,节点6代表值域为 [ 3 , 4 ] [3,4] [3,4]的区间。
可持久化的概念:可持久化实质上就是存储该数据结构所有的历史状态,以达到高效的处理某些信息的目的。
可持久化线段树主要用来解决什么样的问题呢,就像下面这个:
- 单点修改
- 查询在第x次修改前的区间和
因为要查询在第x操作前的区间和,那么我们肯定要能够用一种奇妙的做法来保存第x次操作前的线段树,那怎么保存呢?首先,肯定是不能对每一次操作新建一棵线段树,因为线段树本来就是很耗空间的一个东西,那么我们细细想一下,发现每一次只会修改线段树上的一条链,也就是说,我们不需要新建整整一棵线段树,只需要新建这一条链上的节点就好了!就比如假如我们有一个数列{2,4,1,3},那么建出来的线段树就是这样的(节点上的值代表自己管理范围内的和,红色字为编号):
因为线段树的性质,所以每个点的左子树的值域区间$\leq 右 子 树 的 值 域 区 间 。 因 此 , 我 们 可 以 先 计 算 出 左 子 树 区 间 内 共 有 多 少 个 数 , 不 妨 记 为 右子树的值域区间。因此,我们可以先计算出左子树区间内共有多少个数,不妨记为 右子树的值域区间。因此,我们可以先计算出左子树区间内共有多少个数,不妨记为cnt$
- 如果 k ≤ c n t k\leq cnt k≤cnt,就说明第 k k k小的数一定是在左子树的值域内,即在左子树区间中。于是问题就转化为"在左子树的值域内(左子树区间)找到第 k k k小的数"。
- 如果 k > c n t k>cnt k>cnt,就说明第 k k k小的数一定是在右子树的值域内,即在右子树区间中。因为我们已经求出了左子树区间中有 c n t cnt cnt个数字,那么这个第 k k k小的数在右子树区间就是第 k − c n t k-cnt k−cnt小的数,于是问题就转化为"在右子树的值域内(右子树区间)找第 k − c n t k-cnt k−cnt小的数"。
问题转换到任意区间
我们要用 [ l i , r i ] [l_i,r_i] [li,ri]区间的数建立权值线段树,我们发现可以用前缀和来维护:只要用预处理大法分别以 [ 1 , l i ] [1,l_i] [1,li]和 [ 1 , r i ] [1,r_i] [1,ri]的数建立权值线段树,每个点的值对位相减即可。
关键性质
我们发现 [ 1 , x ] [1,x] [1,x]和 [ 1 , x + 1 ] [1,x+1] [1,x+1]区间内的数所建立的权值线段树的差异仅在一条链上: A [ x + 1 ] A[x+1] A[x+1]的次数+1。也就是不超过 l o g 2 n log_2n log2n个点。可以考虑动态开点:
- 与上一个权值线段树没有差异的地方直接指引过去
- 有差异,单独新增一个点
这样就可以预处理出 [ 1 , x ] ( 1 ≤ x ≤ n ) [1,x](1\leq x\leq n) [1,x](1≤x≤n)所有的权值线段树了。
暂时不考虑题目询问的区间 [ l i , r i ] [l_i,r_i] [li,ri]。如果能够在线段树上维护"序列A有多少个数落在值域区间[L,R]内(记为 c n t L , R cnt_{L,R} cntL,R)",那么只需要比较 c n t L , m i d cnt_{L,mid} cntL,mid与k的大小关系,即可确定第k小数是 ≤ m i d \leq mid ≤mid还是 > m i d > mid >mid,从而进入线段树的左、右子树。在有 l i , r i l_i,r_i li,ri限制下,可以用可持久化线段树。
我们首先对序列A进行离散化,设离散化后A[i]的值为 H [ A [ i ] ] ∈ [ 1 , T ] H[A[i]]\in [1,T] H[A[i]]∈[1,T]。在区间 [ 1 , T ] [1,T] [1,T]上建立可持久化线段树,线段树上的每一个节点都保存一个值cnt,表示该节点代表的值域区间[L,R]中一共插入了多少个数,初始化cnt=0。然后我们对每一个A[i],在可持久化线段树上执行 H [ A [ i ] ] H[A[i]] H[A[i]]的"单点修改",将其cnt加1.线段树每个内部节点的cnt值=左子节点的cnt+右子节点的cnt。此时,可持久化线段树中"以root[i]为根的线段树"的值域区间[L,R],就保存了A的前i个数有多少个落到了值域区间[L,R]中。
接下来考虑每个询问 l i , r i l_i,r_i li,ri。这里有一个重要的性质:以 r o o t [ l i ] root[l_i] root[li]和以 r o o t [ r i ] root[r_i] root[ri]为根的两棵线段树对值域的划分是相同。换言之,除了cnt值不同之外,两颗线段树的内部结构和每个节点代表的值域区间完全对应。这意味着" r o o t [ r i ] root[r_i] root[ri]的值域区间[L,R]的cnt值"减去" r o o t [ l i ] root[l_i] root[li]的值域区间[L,R]的cnt值"就等于 A [ l i ] A [ r i ] A[l_i]~A[r_i] A[li] A[ri]中有多少个数落在值域区间[L,R]内,也就是说可持久化线段树中两个代表相同值域的节点具有可减性。
代码
#include<iostream>
#include<algorithm>
#include<vector>
using namespace std;
const int N=100010;
int n,m;
int a[N];
int root[N],idx;
vector<int>nums;
struct Node{
//因为可持久化线段树它已经不再是一棵完全二叉树了,所以我们不能用层次序编号,而是改为直接记录每个节点
//的左右子节点的编号,就与trie的字符指针类似。
int l,r; //l是左子节点的编号,r是右子节点的编号
int cnt;//表示该节点所代表的值域区间[L,R]中一共插入了多少个数
}tr[4*N+17*N];
//二分找到x离散化后的值
int find(int x)
{
return lower_bound(nums.begin(),nums.end(),x)-nums.begin();
}
int build(int start,int end)
{
int p=++idx; //给p分配一个节点编号
//此时到了叶子节点,返回p的节点编号
if(start==end)
return p;
int mid=(start+end)/2;
tr[p].l=build(start,mid);//递归创建左子树
tr[p].r=build(mid+1,end);//递归创建右子树
return p;
}
//在区间[l,r]中插入x
int modify(int p,int l,int r,int x)
{
int q=++idx;//给q分配一个节点编号
tr[q]=tr[p];//q是新版本,p的旧版本,q要继承p拥有的东西
//如果到了叶子节点
if(l==r)
{
tr[q].cnt++;//此时区间长度为1,里面只有一个数,因此cnt+1
return q;
}
int mid=(l+r)/2;
//如果x小于等于mid,递归左子树
if(x<=mid)
tr[q].l=modify(tr[q].l,l,mid,x);
else
//如果x大于mid,递归右子树
tr[q].r=modify(tr[q].r,mid+1,r,x);
//tr[q].l是内部节点q的左子节点编号,tr[q].r是内部节点q的右子节点编号
//内部节点q的cnt=左子节点的cnt+右子节点的cnt
tr[q].cnt=tr[tr[q].l].cnt+tr[tr[q].r].cnt;
return q;
}
//q是第r个版本的,p是第l-1个版本的可持久化线段树,在区间[l,r]中查询第k小的数
int query(int q,int p,int l,int r,int k)
{
//如果到了叶子节点,此时只有一个数,返回即可
if(l==r)
return r;
int mid=(l+r)/2;
//先算出在区间[l,r]内,第r版本的左区间的cnt减去第l-1版本的左区间的cnt
//即可知道序列a由多少个数落在值域区间[l,r]的左边
int cnt=tr[tr[q].l].cnt-tr[tr[p].l].cnt;
//如果k小于等于cnt,说明第k小的数是在左区间
if(k<=cnt)
return query(tr[q].l,tr[p].l,l,mid,k);
//k>cnt,说明第k小的数是在右区间的第k-cnt个
else
return query(tr[q].r,tr[p].r,mid+1,r,k-cnt);
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
{
scanf("%d",&a[i]);
nums.push_back(a[i]);
}
sort(nums.begin(),nums.end()); //排序
nums.erase(unique(nums.begin(),nums.end()),nums.end()); //去重
root[0]=build(0,nums.size()-1); //先建立第0个版本的可持久化线段树
//依次输入这n个数,然后建立n个版本的可持久化线段树
for(int i=1;i<=n;i++)
root[i]=modify(root[i-1],0,nums.size()-1,find(a[i]));
while(m--)
{
int l,r,k;
scanf("%d%d%d",&l,&r,&k);
int i=query(root[r],root[l-1],0,nums.size()-1,k);
printf("%d\n",nums[i]);
}
return 0;
}