基本数据结构总结
线性的基本数据结构
栈
先进后出,没啥好讲的,主要是应用。
关于表达式
栈可以做表达式相关的问题。
几个名词:
逆波兰式是后缀表达式,波兰式是前缀表达式。
后缀表达式求值
可以用栈做到
- 建立一个栈,储存表达式中的数字。
2.从前往后扫后缀表达式。遇到数字就入栈,遇到运算符就从栈中取出数字做运算,运算结果再入栈。注意减这种有顺序的运算。
- 最后栈中还剩一个数,即答案。
中缀表达式求值
Sol_1:
先把中缀表达式转成后缀表达式,再求值,
-
建立一个栈,储存表达式中的运算符。
-
从前往后扫表达式,开始大力分讨:若是数字,输出;若是
(
,入栈;若是)
,不断输出栈顶直到栈顶为(
,再将(
出栈但不用输出;若是运算符 ,不断取出栈顶直到 的优先级大于栈顶,再将 入栈(优先级:*/
>+-
>(
)。 -
最后依次取出栈中剩余元素并输出。
转换之后再对后缀表达式求值即可。
Sol_2:
可以递归,
考虑求出中缀表达式中
-
考虑没有被任何括号包含的运算符,先考虑加减,再考虑乘除:若存在加减号,选择最靠右的一个,分左右两边递归;若存在乘除号,选最靠右的一个,分左右两边递归。
-
若不存在没有被任何括号包含的运算符:若首尾都是括号,则返回
的答案;否则这个区间是一个数字,返回其数值。
表达式树
更强大的处理表达式的东西,通过栈建出来。
-
建立一个栈,储存表达式树的节点编号。
-
从前往后扫后缀表达式:若是数字,新建一个节点,以当前数字为值,左右儿子为空,入栈;若是运算符,新建一个节点,以当前运算符为值,先从栈中取
,再从栈中取 ,合并起来再入栈,注意左右儿子取出的顺序!
表达式树一定是一棵二叉树。其前序遍历为前缀表达式,中序遍历为中缀表达式,后序遍历为后缀表达式。
建好后就可以DFS查询了,注意根据题意剪枝。
对顶栈
典:维护一段文本以及光标,支持光标处插入删除,光标处查询,前后移动光标。
Sol:
光标前后维护两个栈即可。
单调栈
就是保证栈内的元素具有单调性,是类似单调队列的东西。
二维的查询就尝试压缩到一维上,将信息拍平。
栈的神秘操作
维护一个栈,支持插入删除,查最大次大。
Sol:
入栈出栈可以看成建树的过程。入栈就是跳到儿子,出栈就是跳到父亲。查最大次大就是查树上前缀最大次大。于是可以维护了。
也可以把最大值单独维护一下,其他的元素塞到set里面。
栈的终极Trick:baka's trick
首先思考双栈模拟双端队列。
Sol:
维护两个栈,使得两个栈拼起来就是要维护的队列。
当一个栈为空却要pop
时,将另一个栈从中间砍成两半,暴力重构两个栈的信息。
可以证明是
现在来思考这个东西与双指针的关系。
我们发现维护双指针很像维护双端队列。
baka's trick可以解决双指针中这样的困境:
结果便于支持加入,合并,但删除的复杂度错了。
可以联系一下回滚莫队,我们把删除操作改为撤销操作,不能撤销了就暴力重构。
具体而言:
-
在两个指针
之间再维护一个 ,初始时 。 -
我们不再单纯地维护
这一段的值,而是维护 这一段的后缀信息,以及 这一段的前缀信息。 -
若移动指针后
,便使 ,然后暴力重构 之间的信息。 -
查询时将两段拼起来就好。
这样子在均摊下是对的,但我不会证。
队列
先进先出,没啥好讲的,主要是运用。
单调队列
很强,可以优化DP,或者自成一题。优化DP的方式看DP去。
单调队列保证队列中的东西具有单调性,方便查询最值(或类似的东西)。
一种常见模型是单调队列配合双指针。观察题目性质可以发现指针移动的单调性,然后两个指针之间用单调队列维护一下。
例如:
给定一个长度为
对于
Sol:
显然无论如何改成
观察一下,对于一段合法的区间
所以双指针套单调队列。
实现细节见代码。
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;
}
树型基本数据结构
堆
堆是一棵树,每个点有一个权值,每个节点的权值都
可以支持:插入一个值,删除最值,查询最值,合并堆。
一些强大的堆可以支持高效合并,还有更强大的可以持久化。
优先队列
STL中的优先队列priority_queue就是一个二叉堆。其满足堆的性质,且是一棵完全二叉树。
插入:直接在最后新建节点,然后不断向上尝试与父亲交换。
删除:将根节点与最后一个节点交换,再把最后一个节点删除。对于当前根节点,不断尝试将其与儿子交换以满足根的性质。
可以发现操作前后都满足完全二叉树的性质,树高总是
对顶堆
维护一个序列,支持插入一个元素,查询第
维护一个大根堆和一个小根堆。小根堆维护前
左偏树/可并堆
考虑一般的数据结构如何合并?
朴素的方法是随便将其中一个拆开,每个点合并到另一个中。
启发式合并:对暴力进行了一些优化。每次合并,我们将规模较小的合并到规模较大的中去。
分析一下复杂度,每次合并规模至少翻倍,所以一个点最多被合并
已经很优秀了,但是想要
使用左偏树。
定义
如果要合并两棵树
每递归一层
插入:单个节点作为堆合并。
删除堆顶:将堆顶删了,合并根的左右儿子即可。
板子
#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;
}
更强的运用大概和线段树合并差不多。
笛卡尔树
似乎也可以很难。
对于
一般来说,
考虑增量构造,每次向已经建好的树中插入一个点,那么这个点的
我们用一个栈维护右链,每次插入一个点时,就类似单调栈一样维护,找到使新点满足堆性质的父节点(注意父节点为空就是新点成为了根节点),将新点作为父节点的右儿子。然后为保持二叉搜索树性质,将原右链上父节点下面的那个点作为新点的左儿子(注意是否为空,别越界)。
构建是线性的。
性质:一棵子树包含一端连续区间,区间的最值为根节点。可以以之为结构做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;
}
}
先判是否在同一集合中,再判是否能满足条件或者尝试连边。连边可以类似向量一样考虑来确定权值,因为维护的权值一般是带有某种方向性的(向父亲方向)。
种类并查集
类似敌人的敌人是朋友这种具有传递性的关系。使用类似拆点的做法,将
一定要考虑传递性,例如不等关系没有传递性
可撤销并查集
使用一个undo
操作撤销上一次合并。
路径压缩后信息改太多了,不太好。于是需要按秩合并,维护一下sz
。
每次只能撤销上一次的,用一个栈存下操作序列(其中的元素
然后撤销操作就是取出栈顶,修改其父亲的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;
}
持久化并查集
就是要把
直接主席树维护一下,注意这里不能路径压缩,要按秩合并,可以保证树高
哈夫曼树
大概是论文题,了解一下。
对一棵树,叶子节点都带权,从根节点到各叶子节点的路径长度与相应叶子节点权值的乘积之和称为树的带权路径长度。
哈夫曼树最小化了带权路径长度。
二叉哈夫曼树
-
将每个节点作为一个二叉树的根节点,扔到堆里面。
-
每次从堆里面取出两个权值最小的根,作为新建的根的左右儿子,并使左右儿子的权值之和作为新根的权值。再把新根扔进堆里。
叉哈夫曼树
与二叉哈夫曼树类似,同样取出前
但是会出现问题,若最后合并出的根节点儿子不足
于是考虑补权值为
注意到每次取出
初始
于是当
于是不断塞
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)