浅谈可持久化数据结构
可持久化数据结构
可持久化
众所周知,大多数的数据结构都支持我们对它进行查询和修改。对于普通的数据结构来说,“修改”通常是没有回头路的,我们只能对唯一一个版本进行查询。那么当我们需要用到历史版本的时候我们又该怎么办呢?
例1. 洛谷P3919
维护一个长度为N的数组,有以下两种操作
1. 在某一个历史版本上修改某一个位置
2. 访问某一个历史版本的某一个位置
此外,在完成一次操作后会生成一个新版本。
这种维护历史版本的能力就叫可持久化。
可持久化线段树
对于实现可持久化的方法,我们最容易想到的就是开一个 O(N2) 的空间,把所有版本都存储下来。但这样显然会有很多空间是浪费的,因为相较于被修改的部分来说,两个版本之间完全相同的部分显然会更多。与线性结构相比,树型结构更擅长帮助我们分清楚这两个部分,而树形结构中最擅长处理这种修改查询问题的就是线段树。所以我们先介绍一下利用可持久化线段树实现可持久化数组
可持久化数组
回忆线段树的修改流程,我们会发现所有的操作都只跟一条链有关 (下放lazytag是个例外,但也跟一条链差不多) ,因此我们在生成新版本的时候,对于未修改的点可以直接使用旧版本的节点,只有发生修改的点才需要新建一个新节点来代替旧节点,这样空间复杂度就可以降到 O(NlogN) ,而修改查询这些都不会收到影响。
以图中这个线段树为例,它被修改了第 4 号节点,这是它发生的变化
理解了概念之后,代码部分就变得很简单了,这里放上例1的 ac 代码,仅供参考
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cmath>
using namespace std;
inline int read()
{
int num = 0, fu = 1;
char ch = getchar();
while(ch < '0' || ch > '9'){if(ch == '-') fu = -1; ch = getchar();}
while(ch >= '0' && ch <= '9'){num = (num << 3) + (num << 1) + ch - '0'; ch = getchar();}
return num * fu;
}
const int MAXN = 1000100;
struct N
{
int ls, rs;//左右孩子
int val;//权值 这道题不需要维护啥,只需要在叶子节点存好值就行
};
N tree[MAXN * 20];//大概要开20*n的大小,NlogN大概就这么大
int a[MAXN];
int root[MAXN];//存储根节点编号的数组,数组的下标也表示这个根的版本号
int cnt = 0, tot = 0;//cnt计数点,tot计数树
int build(int l, int r)//递归建树,这里用用模拟指针的方式来建树
{
int mid = (l + r) >> 1;
int cur = ++cnt;
if(l == r)
{
tree[cur].val = a[l];
}
else
{
tree[cur].ls = build(l, mid);
tree[cur].rs = build(mid+1, r);
}
return cur;
}
int newnode(int num)//生成一个新节点,这个节点的数值暂时和编号为num的相同
{
tree[++cnt] = tree[num];
return cnt;
}
int find(int t, int v, int l, int r)//在区间[l, r]中寻找第 t 个数 其实和普通线段树完全一致
{
if(l == r) return tree[v].val;
int mid = (l + r) >> 1;
if(mid >= t) return find(t, tree[v].ls, l, mid);
else return find(t, tree[v].rs, mid + 1, r);
}
void change(int t, int u, int v, int num, int l, int r)//在区间[l, r]中找到第t个数,并修改为num u,v分别表示旧版本和新版本中代表当前区间的节点编号
{
if(l == r)
{
tree[v].val = num;
return;
}
int mid = (l + r) >> 1;
if(mid >= t)
{
tree[v].ls = newnode(tree[u].ls);
change(t, tree[u].ls, tree[v].ls, num, l, mid);
}
else
{
tree[v].rs = newnode(tree[u].rs);
change(t, tree[u].rs, tree[v].rs, num, mid + 1, r);
}
}
int main()
{
int n = read(), m = read();
for(int i=1; i<=n; i++) a[i] = read();
build(1, n);
root[tot] = 1;
for(int i=1; i<=m; i++)
{
int v = read(), ch = read();
if(ch == 1)
{
int loc = read(), num = read();
root[++tot] = newnode(root[v]);
change(loc, root[v], root[tot], num, 1, n);
}
else
{
int loc = read();
root[++tot] = newnode(root[v]);
int ans = find(loc, root[v], 1, n);
printf("%d\n", ans);
}
}
return 0;
}
静态可持久化线段树————主席树初步
在介绍这部分前,我们先讨论一下权值线段树。权值线段树可以看作一个线段树化的桶,代表区间 [l, r] 的节点维护的值为序列中属于 [l, r] 的数字数量。
那么对于两个结构完全相同的权值线段树 a 和 b,我们可以对他们进行运算。a (+/-) b
可以看作两个线段树中所有的对应位置节点的权值进行相加或相减得到的新线段树;再引入一个整数 c ,a * c
可以看作将 a 中每个节点的权值都乘以 c。
如果以上两种运算你觉得难以理解的话,建议暂停下来想象一下。请想通了之后再向下看。
回到可持久化线段树,我们先暂时放下可持久化的思想,重新审视可持久化线段树的结构特点,我们会发现这东西也可以看作一个由线段树组成的数组,再加上刚刚介绍的权值线段树的运算,很难让人不想搞出点名堂。当年一位名叫黄嘉泰的前辈就是利用了可持久化权值线段树的这个性质,解决了区间第 k 大问题,因为前辈的名字缩写与当时的主席相同,故得名主席树。
例2.hdu2665/luoguP3834 两个都交一交嘛,又不收你钱
给一个长度为 n 的数组,有 m 次询问,每次询问一个数组下标区间 [l, r] 和一个整数 k,要求给出这个区间中第 k 小的数
首先把问题简化,如果我们只需要求出整个数组中的第 k 小数,不难想到我们只需要把数组离散化之后塞到一个权值线段树中,利用和平衡树中相似的办法找到答案。
现在问题变成了给定的区间,那么我们就要想个办法把这个区间所代表的权值线段树搞出来。那么主席树是如何解决这个问题的呢?先说建树的方法:初始版本的线段树完全是空的,所有节点权值都为 0,之后按顺序插入所有数字,每插入一个数字都要新建一个新版本。查询时对于区间 [l, r],我们只需要对 第 r 个版本和第 l-1 个版本做差,这个插值就是代表了区间 [l, r] 的权值线段树,接下来在这棵树上查找即可。
这里其实用到了我们无比熟悉的前缀和思想。第 r 个版本代表区间 [1, r] ,第 l-1 个版本代表区间 [1, l-1],那么显然把这两个做差就可以只留下区间 [l, r] 的部分了。注意,因为两个树做差就是是所有对应位置的节点权值做差,所以我们并不需要新建一颗树,其实同时遍历两棵树即可达到要求。
AC代码,依旧仅供参考
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cmath>
#include <map>
using namespace std;
inline int read()
{
int num = 0, fu = 1;
char ch = getchar();
while(ch < '0' || ch > '9'){if(ch == '-') fu = -1; ch = getchar();}
while(ch >= '0' && ch <= '9'){num = (num << 3) + (num << 1) + ch - '0'; ch = getchar();}
return num * fu;
}
const int MAXN = 200010;
struct N
{
int ls, rs;
int val;
N(){ls = rs = 0; }
};
N tree[MAXN * 20];
int cnt = 0;
int root[MAXN], tot = 0;
int build(int l, int r)
{
int cur = ++cnt;
if(l != r)
{
int mid = (l + r) >> 1;
tree[cur].ls = build(l, mid);
tree[cur].rs = build(mid + 1, r);
}
tree[cur].val = 0;
return cur;
}
int newnode(int t)
{
tree[++cnt] = tree[t];
return cnt;
}
void add(int num, int l, int r, int u, int v)
{
tree[v].val++;
if(l == r) return;
int mid = (l + r) >> 1;
if(num <= mid)
{
tree[v].ls = newnode(tree[u].ls);//因为tree[v]本身就是从 tree[u]拷贝来的 所以tree[v].rs不用再赋值一次了,下同理
add(num, l, mid, tree[u].ls, tree[v].ls);
}
else
{
tree[v].rs = newnode(tree[u].rs);
add(num, mid+1, r, tree[u].rs, tree[v].rs);
}
}
int find(int u, int v, int l, int r, int k)//思路跟当初在二叉排序树上找第k大时是完全一致的
{
if(l == r) return l;
int x = tree[tree[v].ls].val - tree[tree[u].ls].val;
int mid = (l + r) >> 1;
if(x >= k) return find(tree[u].ls, tree[v].ls, l, mid, k);
else return find(tree[u].rs, tree[v].rs, mid + 1, r, k - x);
}
int a[MAXN], b[MAXN];
int main()
{
int n = read(), m = read();
for(int i=1; i<=n; i++)
{
a[i] = read();
b[i] = a[i];
}
//离散化
sort(b+1, b+n+1);
int l = unique(b+1, b+n+1) - b - 1;//unique(): 把数组去重,使用前需排序,l为去重后的长度
root[0] = build(1, l);
for(int i=1; i<=n; i++)
{
int t = lower_bound(b+1, b+l+1, a[i]) - b;//找到a[i]离散化后的值
root[tot + 1] = newnode(root[tot]);
tot++;
add(t, 1, l, root[tot - 1], root[tot]);
}
for(int i=1; i<=m; i++)
{
int x = read(), y = read(), k = read();
int t = find(root[x - 1], root[y], 1, l, k);
printf("%d\n", b[t]);
}
return 0;
}
动态可持久化线段树————主席树进阶
动态,顾名思义,相比之前的问题,这里要多支持一个修改的操作。沿用前面的思想,如果对刚刚的主席树进行修改的话,就像是让前缀和数组修改一样,是非常浪费时间的。还记得前缀和是怎么解决这个问题的吗?答案是引入树状数组,这里也是如此。把这个用线段树组成的大数组按照树状数组的方式维护,就可以方便的进行修改和查询了。
可持久化并查集
写作并查集,前置知识却是利用可持久化线段树实现可持久化数组,过分!回顾并查集,其实不过是一个 fa[a] = b
,为了可持久化,我们就用可持久化数组来维护 fa[i]
。注意这里不能再使用路径压缩了,道理很简单,可持久化要尽可能减少修改的次数。但是我们依然保留了一种优化方式:在维护 fa[i]
的同时维护一个 dep[i]
,表示这个节点的深度,保证在合并时是深度较小的点向深度较大的点合并即可。
可持久化分块
利用和可持久化线段树相似的思想,将每块都编好序号,用一个 O(√n) 的数组来记录一个版本的所有块。当一个块要被修改时,新建一个值为修改后的块的新块,用一个新数组保存新版本所有的块编号即可。
可持久化字典树
依旧与可持久化线段树的思想类似,这里的可持久化主要体现在插入新串,这个插入过程影响的节点数量也是O(logN)的,因此插入时像线段树一样生成一个新的链即可。
参考资料
可持久化数据结构研究————陈立杰 太经典了,必读
可持久化并查集————pengym 除了讲解很细致,博客背景也很好看(
主席树题单集合
洛谷P3834题解 我主要参考了第一篇(id:无晴)
题单
题号 | 标签 | 难度 |
---|---|---|
洛谷P3919 | 可持久化数组 | ★★ |
洛谷P1383 | 可持久化数组 | ★★★ |
洛谷P3814 | 静态主席树 | ★★★ |
HDU2665 | 静态主席树 | ★★★ |
HDU4417 | 静态主席树 | ★★★ |
HDU5923 | 可持久化并查集 | ★★★ |
洛谷P4735 | 可持久化字典树 | ★★★ |
SPOJ COT | 静态主席树 LCA | ★★★★ |
HDU5820 | 主席树 | ★★★★ |
持续更新中... |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】