基本数据结构总结
线性的基本数据结构
栈
先进后出,没啥好讲的,主要是应用。
关于表达式
栈可以做表达式相关的问题。
几个名词:
逆波兰式是后缀表达式,波兰式是前缀表达式。
后缀表达式求值
可以用栈做到\(O(n)\)。
- 建立一个栈,储存表达式中的数字。
2.从前往后扫后缀表达式。遇到数字就入栈,遇到运算符就从栈中取出数字做运算,运算结果再入栈。注意减这种有顺序的运算。
- 最后栈中还剩一个数,即答案。
中缀表达式求值
Sol_1:
先把中缀表达式转成后缀表达式,再求值,\(O(n)\)。
-
建立一个栈,储存表达式中的运算符。
-
从前往后扫表达式,开始大力分讨:若是数字,输出;若是
(
,入栈;若是)
,不断输出栈顶直到栈顶为(
,再将(
出栈但不用输出;若是运算符\(op\),不断取出栈顶直到\(op\)的优先级大于栈顶,再将\(op\)入栈(优先级:*/
>+-
>(
)。 -
最后依次取出栈中剩余元素并输出。
转换之后再对后缀表达式求值即可。
Sol_2:
可以递归,\(O(n^2)\)。
考虑求出中缀表达式中\([l,r]\)的值,我们要求的就是\([1,n]\)。
-
考虑没有被任何括号包含的运算符,先考虑加减,再考虑乘除:若存在加减号,选择最靠右的一个,分左右两边递归;若存在乘除号,选最靠右的一个,分左右两边递归。
-
若不存在没有被任何括号包含的运算符:若首尾都是括号,则返回\([l+1,r-1]\)的答案;否则这个区间是一个数字,返回其数值。
表达式树
更强大的处理表达式的东西,通过栈建出来。
-
建立一个栈,储存表达式树的节点编号。
-
从前往后扫后缀表达式:若是数字,新建一个节点,以当前数字为值,左右儿子为空,入栈;若是运算符,新建一个节点,以当前运算符为值,先从栈中取\(r\),再从栈中取\(l\),合并起来再入栈,注意左右儿子取出的顺序!
表达式树一定是一棵二叉树。其前序遍历为前缀表达式,中序遍历为中缀表达式,后序遍历为后缀表达式。
建好后就可以DFS查询了,注意根据题意剪枝。
对顶栈
典:维护一段文本以及光标,支持光标处插入删除,光标处查询,前后移动光标。
Sol:
光标前后维护两个栈即可。
单调栈
就是保证栈内的元素具有单调性,是类似单调队列的东西。
二维的查询就尝试压缩到一维上,将信息拍平。
栈的神秘操作
\(Question\):
维护一个栈,支持插入删除,查最大次大。
Sol:
入栈出栈可以看成建树的过程。入栈就是跳到儿子,出栈就是跳到父亲。查最大次大就是查树上前缀最大次大。于是可以维护了。
也可以把最大值单独维护一下,其他的元素塞到set里面。
栈的终极Trick:baka's trick
首先思考双栈模拟双端队列。
Sol:
维护两个栈,使得两个栈拼起来就是要维护的队列。
当一个栈为空却要pop
时,将另一个栈从中间砍成两半,暴力重构两个栈的信息。
可以证明是\(O(n)\)的。
现在来思考这个东西与双指针的关系。
我们发现维护双指针很像维护双端队列。
baka's trick可以解决双指针中这样的困境:
结果便于支持加入,合并,但删除的复杂度错了。
可以联系一下回滚莫队,我们把删除操作改为撤销操作,不能撤销了就暴力重构。
具体而言:
-
在两个指针\(l,r\)之间再维护一个\(mid\),初始时\(mid=r\)。
-
我们不再单纯地维护\([l,r]\)这一段的值,而是维护\([l,mid]\)这一段的后缀信息,以及\((mid,r]\)这一段的前缀信息。
-
若移动指针后\(l>mid\),便使\(mid\gets r\),然后暴力重构\([l,r]\)之间的信息。
-
查询时将两段拼起来就好。
这样子在均摊下是对的,但我不会证。
队列
先进先出,没啥好讲的,主要是运用。
单调队列
很强,可以优化DP,或者自成一题。优化DP的方式看DP去。
单调队列保证队列中的东西具有单调性,方便查询最值(或类似的东西)。
一种常见模型是单调队列配合双指针。观察题目性质可以发现指针移动的单调性,然后两个指针之间用单调队列维护一下。
例如:
给定一个长度为 \(n\) 的序列,你有一次机会选中一段连续的长度不超过 \(d\) 的区间,将里面所有数字全部修改为 \(0\)。请找到最长的一段连续区间,使得该区间内所有数字之和不超过 \(p\)。
对于 \(100\%\) 的数据,\(1 \le d \le n \le 2 \times 10^6\),\(0 \le p \le 10^{16}\),\(1 \leq w_i \leq 10^9\)。
Sol:
显然无论如何改成\(0\)的区间的长度要尽量取到\(d\),设前缀和为\(sum_i\),该区间的右端点为\(i\),则删掉的数的和为\(sum_i-sum_{i-d}\)。
观察一下,对于一段合法的区间\([l,r]\),\(r\)增大时,\(l\)单调不减。
所以双指针套单调队列。
实现细节见代码。
Code
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=2e6+10;
ll n,p,d,h,t,ans,w[maxn],sum[maxn],q[maxn];
ll max(ll a,ll b){
return a>b?a:b;
}
int main(){
scanf("%lld%lld%lld",&n,&p,&d);
for(int i=1;i<=n;++i){
scanf("%lld",&w[i]);
sum[i]=sum[i-1]+w[i];
}
int j=1;
h=1,t=1;
q[1]=d;
ans=d;
for(int i=d+1;i<=n;++i){
while(h<=t&&sum[i]-sum[i-d]>sum[q[t]]-sum[q[t]-d]) t--;
q[++t]=i;
while(h<=t&&sum[i]-sum[j-1]-(sum[q[h]]-sum[q[h]-d])>p){
j++;
while(h<=t&&q[h]-d+1<j) h++;
}
if(sum[i]-sum[j-1]-(sum[q[h]]-sum[q[h]-d])<=p) ans=max(ans,i-j+1);
}
printf("%lld\n",ans);
return 0;
}
树型基本数据结构
堆
堆是一棵树,每个点有一个权值,每个节点的权值都\(\le\)(或者\(\ge\))它的父亲的权值。\(\le\)就是大根堆,\(\ge\)就是小根堆。
可以支持:插入一个值,删除最值,查询最值,合并堆。
一些强大的堆可以支持高效合并,还有更强大的可以持久化。
优先队列
STL中的优先队列priority_queue就是一个二叉堆。其满足堆的性质,且是一棵完全二叉树。
插入:直接在最后新建节点,然后不断向上尝试与父亲交换。
删除:将根节点与最后一个节点交换,再把最后一个节点删除。对于当前根节点,不断尝试将其与儿子交换以满足根的性质。
可以发现操作前后都满足完全二叉树的性质,树高总是\(\log n\)的,所以插入删除都是\(O(\log n)\)的,查询\(O(1)\)。
对顶堆
\(Question\):
维护一个序列,支持插入一个元素,查询第\(k\)大,删除第\(k\)大,将\(k\)加一减一。
\(Sol\):
维护一个大根堆和一个小根堆。小根堆维护前\(k-1\)大,大根堆维护剩下的。查询直接在大根堆里查就行了。调整很简单。
左偏树/可并堆
考虑一般的数据结构如何合并?
朴素的方法是随便将其中一个拆开,每个点合并到另一个中。
启发式合并:对暴力进行了一些优化。每次合并,我们将规模较小的合并到规模较大的中去。
分析一下复杂度,每次合并规模至少翻倍,所以一个点最多被合并\(\log n\)次,一共\(n\)个点,所以会进行\(n\log n\)次插入操作。像这样合并两个堆,由于插入\(O(\log n)\),总时间复杂度\(O(n\log^2 n)\)。
已经很优秀了,但是想要\(O(n\log n)\)的堆合并?
使用左偏树。
定义 外结点 为左儿子和右儿子中至少一个为空的结点。
定义\(dis_u\)表示\(u\)到子树内的 外结点 的最小距离。定义外结点 \(dis_u=0\),空节点 \(dis_u=-1\)。
由于完全二叉树中根的 \(dis\) 达到上界,所以根的 \(dis_{rt}=O(\log n)\)。
左偏树既满足二叉堆的性质,也满足每个节点左儿子的\(dis\)大于右儿子的\(dis\)。
如果要合并两棵树\(x\)和\(y\),(默认小根堆)令\(x\)为堆顶更小的树,要将\(y\)合并到\(x\)上。
将\(x\)的根作为合并后的根,保留这个根的左儿子,递归地合并其右儿子和另一个堆(其实就像是把\(y\)挂在\(x\)最靠右的叶子节点上,再不断向上调整,但是不完全一样,下降中途也会发生交换)。
为保持左偏的性质,合并后若左儿子的\(dis\)小于右儿子了,就交换左右儿子。然后更新当前点的 \(dis\)。
每递归一层\(dis\)少\(1\),根的 \(dis\) 是 \(O(\log n)\),所以一次合并是\(O(\log n)\)的。
插入:单个节点作为堆合并。
删除堆顶:将堆顶删了,合并根的左右儿子即可。
可能不用真的将空节点的 \(dis\) 赋成 \(-1\),反正判断好就行。
板子
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+10;
int n,m,val[maxn],dis[maxn],rt[maxn],son[2][maxn];
int find(int u){
if(u==rt[u]) return u;
return rt[u]=find(rt[u]);
}
int merge(int x,int y){
if(!x||!y) return x+y;
if(val[x]>val[y]||(val[x]==val[y]&&x>y)) swap(x,y);
son[1][x]=merge(son[1][x],y);
if(dis[son[0][x]]<dis[son[1][x]]) swap(son[0][x],son[1][x]);
dis[x]=dis[son[1][x]]+1;
rt[x]=rt[son[0][x]]=rt[son[1][x]]=x;
return x;
}
void del(int x){
val[x]=-1;
rt[son[0][x]]=son[0][x],rt[son[1][x]]=son[1][x];
rt[x]=merge(son[0][x],son[1][x]);
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;++i){
scanf("%d",&val[i]);
rt[i]=i;
}
for(int i=1,op,x,y;i<=m;++i){
scanf("%d",&op);
if(op==1){
scanf("%d%d",&x,&y);
if(find(x)==find(y)||(val[x]==-1||val[y]==-1)) continue;
rt[x]=rt[y]=merge(rt[x],rt[y]);
}
else if(op==2){
scanf("%d",&x);
if(val[x]==-1) puts("-1");
else{
printf("%d\n",val[find(x)]);
del(rt[x]);
}
}
}
return 0;
}
更强的运用大概和线段树合并差不多。
笛卡尔树
似乎也可以很难。
对于\(n\)个二元组\((k,w)\),使\(k\)满足二叉搜索树性质,\(w\)满足堆性质。
一般来说,\(k\)为下标,\(w\)为权值,分大根堆和小根堆,这里以小根堆为例。
考虑增量构造,每次向已经建好的树中插入一个点,那么这个点的\(k\)一定是最大的,会接到树上一直向右跳的链的右端,再根据堆性质维护。
我们用一个栈维护右链,每次插入一个点时,就类似单调栈一样维护,找到使新点满足堆性质的父节点(注意父节点为空就是新点成为了根节点),将新点作为父节点的右儿子。然后为保持二叉搜索树性质,将原右链上父节点下面的那个点作为新点的左儿子(注意是否为空,别越界)。
构建是线性的。
性质:一棵子树包含一端连续区间,区间的最值为根节点。可以以之为结构做DP。
板子
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=1e7+10;
int n,tp,p[maxn],stk[maxn],a[maxn],ls[maxn],rs[maxn];
int read(){
int f=1,rs=0;
char ch=getchar();
while(ch<'0'||ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9') rs=rs*10+(ch-'0'),ch=getchar();
return f*rs;
}
int main(){
n=read();
for(int i=1;i<=n;++i) a[i]=read();
for(int i=1;i<=n;++i){
int k=tp;
while(k>0&&a[stk[k]]>a[i]) --k;
if(k) rs[stk[k]]=i;
if(k<tp) ls[i]=stk[k+1];
stk[++k]=i;
tp=k;
}
ll ans1=0,ans2=0;
for(int i=1;i<=n;++i){
ans1^=1ll*i*(ls[i]+1);
ans2^=1ll*i*(rs[i]+1);
}
printf("%lld %lld\n",ans1,ans2);
return 0;
}
并查集
维护的东西要有传递性!
可以维护元素所在集合。实现出来是一个森林,每棵树是一个集合。
支持合并两个集合,查询所属集合。
合并时同时使用路径压缩和启发式合并才可以保证线性。只使用路径压缩只是平均情况下是线性的。
从一个集合中删除一个节点,就路径压缩后将该节点的父亲设为自己。
移动是类似的。
并查集是很多东西的基础,要灵活掌握。
并查集维护连通性
显然的东西,两个东西在同一个集合中就连通。
带权并查集
我们可以在并查集的边上定义某种权值,在路径压缩的时候进行计算。要对题目进行转化。
实际上权值是存在点上的,描述其与父亲的关系,并且可以计算某个点与父亲的关系,同一个集合中点与点的关系等等。
总之很牛就对了。
实战中注意一下路径压缩和合并的写法。
路径压缩:
int findf(int u){
if(u==fa[u]) return u;
int rt=findf(fa[u]);
val[u]=(val[u]+val[fa[u]])%mo;
return fa[u]=rt;
}
合并可以看作合并与计算答案的结合体:
合并:
void merge(int u,int v,int w){
int rtu=findf(u),rtv=findf(v);
if(rtu==rtv){
int ret=(val[u]-val[v]+mo)%mo;
if(ret!=w) ans++;
}
else{
fa[rtu]=rtv;
val[rtu]=(w-val[u]+val[v]+mo)%mo;
}
}
先判是否在同一集合中,再判是否能满足条件或者尝试连边。连边可以类似向量一样考虑来确定权值,因为维护的权值一般是带有某种方向性的(向父亲方向)。
种类并查集
类似敌人的敌人是朋友这种具有传递性的关系。使用类似拆点的做法,将\(u\)拆成\(u,u+n\dots u+kn\),与\(u+kn\)在同一集合表示与\(u\)具有第\(k\)种关系。
一定要考虑传递性,例如不等关系没有传递性\(a\ne b\land b\ne c\not\Rightarrow a\ne c\),于是不能使用种类并查集维护。
可撤销并查集
使用一个undo
操作撤销上一次合并。
路径压缩后信息改太多了,不太好。于是需要按秩合并,维护一下sz
。
每次只能撤销上一次的,用一个栈存下操作序列(其中的元素\(u\)表示:上次将\(u\)的父亲从\(u\)本身改为了现在的\(fa_u\))。
然后撤销操作就是取出栈顶,修改其父亲的sz
,把它的父亲重置为自己。
用持久化并查集作为板子。
做法是离线下来,时间之间的转移连成了一棵树,直接DFS,回溯时进行撤销。
板子
#include<bits/stdc++.h>
using namespace std;
#define gc getchar
#define pc putchar
int rd(){
int f=1,r=0;
char ch=gc();
while(!isdigit(ch)){ if(ch=='-') f=-1;ch=gc();}
while(isdigit(ch)){ r=(r<<1)+(r<<3)+(ch^48);ch=gc();}
return f*r;
}
const int maxn=1e5+10;
int n,m;
bool vis[maxn<<1],ans[maxn<<1];
struct state{
int op,a,b,t;
state(){}
state(int x,int y,int z,int w):op(x),a(y),b(z),t(w){}
};
vector<state> e[maxn<<1];
int tp,fa[maxn],sz[maxn],stk[maxn];
int findf(int u){
if(u==fa[u]) return u;
return findf(fa[u]);
}
void merge(int x,int y){// x<-y
x=findf(x),y=findf(y);
if(sz[x]<sz[y]) swap(x,y);
fa[y]=x,sz[x]+=sz[y];
stk[++tp]=y;
}
void undo(){
if(!tp) return;
int u=stk[tp--];
sz[fa[u]]-=sz[u];
fa[u]=u;
}
void dfs(int u){
for(int i=0;i<(int)e[u].size();++i){
int op=e[u][i].op,t=e[u][i].t,a=e[u][i].a,b=e[u][i].b;
if(op==1) merge(a,b),dfs(t),undo();
else if(op==2) dfs(t);
else ans[t]=(findf(a)==findf(b)),dfs(t);
}
}
int main(){
n=rd(),m=rd();
for(int i=1;i<=m;++i){
int op=rd();
if(op==1){
int a=rd(),b=rd();
e[i-1].push_back(state(op,a,b,i));
}
else if(op==2){
int k=rd();
e[k].push_back(state(op,0,0,i));
}
else{
vis[i]=true;
int a=rd(),b=rd();
e[i-1].push_back(state(op,a,b,i));
}
}
for(int i=1;i<=n;++i) fa[i]=i,sz[i]=1;
dfs(0);
for(int i=1;i<=m;++i) if(vis[i]) puts(ans[i]?"1":"0");
return 0;
}
持久化并查集
就是要把\(fa\)给持久化了。
直接主席树维护一下,注意这里不能路径压缩,要按秩合并,可以保证树高\(\log\),然后每次\(fa\)只会改单点。同时主席树上也维护一下秩(就是树高或者子树大小)。
哈夫曼树
大概是论文题,了解一下。
对一棵树,叶子节点都带权,从根节点到各叶子节点的路径长度与相应叶子节点权值的乘积之和称为树的带权路径长度。
哈夫曼树最小化了带权路径长度。
二叉哈夫曼树
-
将每个节点作为一个二叉树的根节点,扔到堆里面。
-
每次从堆里面取出两个权值最小的根,作为新建的根的左右儿子,并使左右儿子的权值之和作为新根的权值。再把新根扔进堆里。
\(k\)叉哈夫曼树
与二叉哈夫曼树类似,同样取出前\(k\)小的合并在一起。
但是会出现问题,若最后合并出的根节点儿子不足\(k\)个,那么显然将一个点从下面移到根节点下面是更优的。
于是考虑补权值为\(0\)的节点,使得最后根节点的儿子总是有\(k\)个。
注意到每次取出\(k\)个节点,加入\(1\)个节点,减少了\(k-1\)个节点。
初始\(n\)个点,最后\(1\)个根节点,一共减少了\(n-1\)个节点。
于是当\(n-1\equiv 0\pmod {k-1}\)时可以是最后根节点的儿子总是\(k\)个。
于是不断塞\(0\)直到满足即可。