OctoberOctoberOctober 主席树 关于主席树的一些总结

前言

学主席树其实已经有一段时间了,之前一直认为自己理解了,也没有写博客总结。在一段时间后看到了可持久化tire树后想总结他们的不同时,才发现自己并不算太理解主席树(关于它们的不同将在tire树总结处有介绍)

这篇博文偏基础,里面有很多我对于主席树的理解,如果有错误欢迎指正,有见解也欢迎提出,蒟蒻感激不尽

概念

主席树(名字奇奇怪怪的),顾名思义是主席发明出来的树 也叫可持久化线段树,发明这种数据结构的dalao名字缩写为(HJT),正好是某个领导人名字的缩写,主席树由此得名

一些其它的东西

好了咱们不讨论这个名字,既然是可持久化数据结构,那么应该可以支持查询历史版本,就比如说查询一个修改前的线段树和修改后的线段树的某些信息(这就是可持久化数组)

好了,通过模板题来理解一下吧

洛谷模板 可持久化数组

大意

维护一个支持修改和查询历史版本某一位置的值的数组(每一次修改更新一个版本)

怎么解决呢?

每一次修改重新开一个数组?单次操作双$O(n)$空间时间双爆炸

题解中有dalao用并查集切掉了这道题(可是我不会)

我们考虑,其实一次操作中的改动是非常小的,没有必要都改

我们考虑将每一个版本建一颗线段树,然后每一次修改的时候新建一个新的根节点,只改需要改的$log(n)$个节点,其他节点完全继承上一个根节点即可,这样保证了一次操作的时间空间均为$log(n)$,就可以通过这道题了

那么,是不是并查集更直观且优秀呢?要这个数据结构干嘛呢?

其实,只要再加一个任意查询区间的操作,就可以充分体现这样做的优越性了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82

const int maxn = 1e8 + 1;
inline int ()
{
register int x = 0, ch = getchar(), f = 1;
while(!isdigit(ch)){if(ch=='-') f = -1;ch = getchar();}
while(isdigit(ch)) x = x * 10 + ch - '0', ch = getchar();
return x*f;
}
struct node{
int l, r;
int val;
}t[maxn];

int n, q;
int cnt;
int a[maxn];
int root[maxn];

int build(int l, int r)
{
int p = ++ cnt;
if(l == r)
{
t[p].val = a[l];
return cnt;
}
int mid = l +r >> 1;
t[p].l = build(l, mid);
t[p].r = build(mid+1, r);
return p;
}

int ins(int pre, int l, int r, int q, int w)
{
int p = ++ cnt;
t[p] = t[pre];//完全继承它的上一个节点
if(l == r)
{
t[p].val = w;//修改
return p;
}
int mid = l + r >> 1;
if(q <= mid) t[p].l = ins(t[p].l, l, mid, q, w);//按线段树的方法递归下去,显然会新建log(n)个节点
else t[p].r = ins(t[p].r, mid + 1, r, q, w);
return p;
}

int query(int p, int l, int r, int q)
{
if(l == r) return t[p].val;
int mid = l + r >> 1;
if(q <= mid) return query(t[p].l, l, mid, q);
else return query(t[p].r, mid + 1, r, q);
}
int main()
{
n = read();
q = read();
for(int i = 1; i <= n; i ++)
{
a[i] = read();
}
root[0] = build(1, n);
for(int i = 1; i <= q; i ++)
{
int op, x, rt, w;
rt = read();
op = read();
x = read();
if(op == 1)
{
w = read();
root[i] = ins(root[rt], 1, n, x, w);//一个版本新开一个root
}
if(op == 2)
{
printf("%dn", query(root[rt], 1, n, x));//查询类似于线段树
root[i] = root[rt];
}
}
}

代码应该还是直观易懂的

没有看懂没有关系,这并不是这个数据结构的主要用途,我们会在下面再进一步讲解

正文

其实要求我们求历史版本的题,(至少我认为没多少)主席树的主要应用并不在此

还是用板子题引入吧

洛谷 主席树

大意

静态区间第k小(即不修改)

思考一下,如果每个区间都是$[1, n]$,怎么搞

普通线段树不符合要求,我们应该建一个权值线段树(即维护的不再是数组,而是数值)

如图

有一个数组 a[4] = {2, 4, 4, 7};

很显然,权值线段树记录的是每个数出现了多少次(图画的丑勿喷)

这样我们就可以很轻松的求出询问是 $[1,n]$ 的区间第k大

这真的不用我再说了吧,就是比如查询第3, 然后一开始在根节点发现左儿子中的数出现次数是1

去右节点查第二 (即3减1) 发现左节点为2 = 2,进入左节点,发现叶节点出现次数2 =2,返回叶节点所代表的数值4 (这再看不懂过分了啊QwQ)

那么,我们现在需要任意定区间查区间第k大,这怎么办呢?

我可以每个区间建一个权值线段树!

枚举左端点和右端点 + 建树$O(n ^ 2 * nlog(n))​$,恭喜咱们创造出一个比暴力还废的算法

然后我们可以发现,权值线段树具有前缀和的性质,即可以相减

比如说我们 $[1, 3], [1, 4]$(这个区间为数组的区间)各建了一个权值线段树

那么我们查寻数组中$[3,4]$区间完全可以用后一个权值线段树的和前一个权值线段树相同的节点上出现的次数相减,就是$[3,4]$区间中该数(该区间)出现的次数(看不懂没关系,后面用代码辅助理解)

在说的通俗易懂一点$[3, 4]$数值4出现的次数就是$[1, 4],[1, 3]$中数值4出现的次数相减

变成$O(n * nlog(n)) ​$ 了呢

但是这样还是不能接受,想一想我们刚才说的?

每一次变化很小,反映在树上只有log(n)的范围

$[1, 3],[1, 4]$在树上的区别呢?显然$log(n)$啊, 请用心理解

因此我们不用开n棵权值线段树,只要一棵主席树就够了

具体实现的时候我们从1 到 n扫一遍数组,每次开一个新的根节点,需要修改的地方修改,其余完全继承上一个根节点的,查询时区间为$[l, r]$ , 我们只要同时查r和l - 1所代表的根节点像刚才一样相减,权值线段树一样查询就可以了

还是代码直观些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86


const int maxn = 1e7 + 1;

struct node{
int l, r;
int sum;
}t[maxn];

int root[maxn];
int a[maxn];
int b[maxn];

inline int ()
{
int x = 0, ch = getchar();
while(!isdigit(ch)) ch = getchar();
while(isdigit(ch)) x = x * 10 + ch -'0', ch = getchar();
return x;
}
int n, q;
int cnt;
int m;

int build(int l, int r)
{
int p = ++ cnt;
t[p].sum = 0;
int mid = l + r >> 1;
if(l < r)
{
t[p].l = build(l, mid);
t[p].r = build(mid + 1, r);
}
return p;
}

int add(int pre, int l, int r, int x)
{
int p = ++ cnt;
t[p] = t[pre];//完全继承上一个节点,只会修改log(n)个节点
t[p].sum ++;
int mid = l + r >> 1;
if(l < r)
{
if(x <= mid) t[p].l = add(t[pre].l, l, mid, x);
else t[p].r = add(t[pre].r, mid + 1, r, x);//一路递归下去
}
return p;
}

int query(int u, int v, int l, int r, int k)
{
if(l >= r) return l;
int x = t[t[v].l].sum - t[t[u].l].sum;//和上文一样所说的相减操作
int mid = l + r >> 1;
if(x >= k) return query(t[u].l, t[v].l, l, mid, k);//和权值线段树一样的查询操作
else return query(t[u].r, t[v].r, mid + 1, r, k - x);
}
int main()
{
n = read();
q = read();
for(int i = 1; i <= n; i ++)
{
a[i] = read();
b[i] = a[i];
}
std:: sort(b + 1, b + 1 + n);
m = std:: unique(b + 1, b + 1 + n) - b - 1;//离散化操作
root[0] = build(1, m);
for(int i = 1; i <= n; i ++)
{
int t = std:: lower_bound(b + 1, b + 1 + m, a[i]) - b;
root[i] = add(root[i - 1], 1, m, t);//每一次新建一个根节点
}
while(q --)
{
int x, y, z;
x = read();
y = read();
z = read();
int t = query(root[x-1], root[y], 1, m, z);//同时查询r和了l - 1所代表的权值线段树
printf("%dn", b[t]);
}
}

请结合理解,我觉得还是很简单易懂的

好啦,我们基础的主席树到这里就学完了(写篇博客真累)

一些我自己的感受吧

  • 主席树并不是一个什么难的数据结构,主要在于权值线段树和前缀思想的应用
  • 主席树其实就是n棵权值线段树,就是用一些特殊手段使得空间和时间开销不是那么大(可以这么理解)
  • 不要认为有了主席树时权值线段树一无是处,线段树合并主席树时是做不到的,有时候必须要建很多权值线段树
  • 主席树很难修改因为最坏的情况下一次要修改n棵树,需要用到树套树(以后会补充)

那么,就先写到这里,以后会逐渐补充一些进阶知识和应用,如果有帮助,不妨关注一下我的博客