最近重新复习了一下一些基础的数据结构,今天介绍四种基础结构的变种应用,分别是,单调栈,单调队列,01字典树和带权并查集,四种基础的数据结构都是很常见的,我们就直接介绍变种后的这些数据结构如何应用。每个部分都介绍一个最经典的例题。
单调栈
首先,顾名思义,单调栈指的就是栈内元素单调增或减的栈,单调栈可以维护的信息就是每个数前后第一个大于/小于他的数。这是单调栈的维护方法。
单调递增栈:在保持栈内元素单调递增的前提下(如果栈顶元素大于要入栈的元素,将将其弹出),将新元素入栈。
单调递减栈:在保持栈内元素单调递减的前提下(如果栈顶元素小于要入栈的元素,则将其弹出),将新元素入栈。
它的时间复杂度是O(n)。
对于单调递增栈,有两个性质常常用到,
1.栈内自己左边的数就是数组左边第一个小于自己的元素。
2.每个元素被弹出是,遇到的就是数组中第一个比自己小的元素。
单调递减栈刚好相反
这里给大家一个维护每个数左边第一个小于它的数的例子
给定一个长度为 N 的整数数列,输出每个数左边第一个比它小的数,如果不存在则输出 −1。
输入格式
第一行包含整数 N,表示数列长度。
第二行包含 N 个整数,表示整数数列。
输出格式
共一行,包含 N 个整数,其中第 i 个数表示第 i 个数的左边第一个比它小的数,如果不存在则输出 −1。
数据范围
1≤N≤105
1≤数列中元素≤109
输入样例:
5
3 4 2 7 5
输出样例:
-1 3 -1 2 2
这是stl的代码
#include<bits/stdc++.h>
using namespace std;
stack<int>a;
int main()
{
int n,x;
cin>>n;
while (n -- )
{
cin>>x;
while(!a.empty()&&a.top()>=x)a.pop();
if(a.empty())cout<<"-1"<<" ";
else cout<<a.top()<<" ";
a.push(x);
}
cout<<endl;
return 0;
}
如果是手写栈
#include<bits/stdc++.h>
using namespace std;
const int N = 100010;
int stk[N], tt;
int main()
{
int n;cin >> n;
while (n -- )
{
int x;
scanf("%d", &x);
while (tt && stk[tt] >= x) tt -- ;
if (!tt) printf("-1 ");
else printf("%d ", stk[tt]);
stk[ ++ tt] = x;
}
return 0;
}
维护右边第一个大于该元素值时,开一个新数组存储即可
单调队列
同样的,顾名思义,单调队列指的就是具有单调性的队列,他也分为单调递增队列和单调递减队列。如何维护我们放在下面,我们先来看看他能解决什么问题
- 单调递增队列:保证队列头元素一定是当前队列的最小值,用于维护区间的最小值。
- 单调递减队列:保证队列头元素一定是当前队列的最大值,用于维护区间的最大值。
什么是区间的最小值,举个简单的例子,给一个长度为5的数组,问区间长度为3的最大值是多少,我们可以分别维护每个区间的最大值,但这样显然复杂度很大,不能很快解决问题,如果用单调队列来维护,就可以保证只用扫一遍就能维护出所有区间段的信息,首先我们要明白单调队列单调的原理。比如单调递增队列。保证队列单调递增的意义在什么呢,实际上,如果后进入队列的值比之前队列的中的值还大,那么无论是当前区间还是之后的区间,当前队列中的值都不可能作为区间的最小值,所以可以直接弹出队列,如果之前的值比现在插入的值小,那就说明,这个区间最小值还是前面的值,但是未来当前插入的值有可能成为最小的值,所以把队列中比他大的数弹出后,在把它插入,可以保证,队头永远是当前区间的最小值,随着移动,队头逐渐弹出,队尾逐渐插入,就可以维护单调队列了
这里用一道单调队列最经典的例题来解释一下
给定一个大小为 n≤106 的数组。
有一个大小为 k 的滑动窗口,它从数组的最左边移动到最右边。
你只能在窗口中看到 k 个数字。
每次滑动窗口向右移动一个位置。
以下是一个例子:
该数组为 [1 3 -1 -3 5 3 6 7],k 为 3。
窗口位置 最小值 最大值
[1 3 -1] -3 5 3 6 7 -1 3
1 [3 -1 -3] 5 3 6 7 -3 3
1 3 [-1 -3 5] 3 6 7 -3 5
1 3 -1 [-3 5 3] 6 7 -3 5
1 3 -1 -3 [5 3 6] 7 3 6
1 3 -1 -3 5 [3 6 7] 3 7
你的任务是确定滑动窗口位于每个位置时,窗口中的最大值和最小值。
输入格式
输入包含两行。
第一行包含两个整数 n 和 k,分别代表数组长度和滑动窗口的长度。
第二行有 n 个整数,代表数组的具体数值。
同行数据之间用空格隔开。
输出格式
输出包含两个。
第一行输出,从左至右,每个位置滑动窗口中的最小值。
第二行输出,从左至右,每个位置滑动窗口中的最大值。
输入样例:
8 3
1 3 -1 -3 5 3 6 7
输出样例:
-1 -3 -3 -3 3 3
3 3 5 5 6 7
题目要求同时维护最大值和最小值,我们就把单调递增队列和单调递减队列一起写了就可以了
#include<bits/stdc++.h>
using namespace std;
const int N = 1000010;
int a[N], q[N];
int main()
{
int n, k;
scanf("%d%d", &n, &k);
for (int i = 0; i < n; i ++ ) scanf("%d", &a[i]);
int hh=0,tt=-1;
for(int i = 0;i < n; i ++ )
{
if(hh<=tt&&i-k+1>q[hh])hh++;//q[hh]指的是当前队头元素的原始位置,判断它是否在这个区间中,是否需要删除
while(hh<=tt&&a[q[tt]]>=a[i])tt--;//如果当前队尾比新插入大,后面不可能用得到,所以可以退出队伍
q[++tt]=i;//当前元素进队;
if(i>=k-1)printf("%d ",a[q[hh]]);
}
puts("");
hh = 0, tt = -1;
for (int i = 0; i < n; i ++ )
{
if (hh <= tt && i - k + 1 > q[hh]) hh ++ ;
while (hh <= tt && a[q[tt]] <= a[i]) tt -- ;
q[ ++ tt] = i;
if (i >= k - 1) printf("%d ", a[q[hh]]);
}
puts("");
return 0;
}
单调队列还可以维护很多区间信息,多重背包问题也可以用单调队列优化,有兴趣也可以去尝试一下,
01字典树
字典树相信大家都已经很熟悉了,就是给一堆字符串,不断往树上插入,我们统计所有字符串分别出现几次。用一个end数组维护结尾即可。在介绍01字典树之前先给一个字典树的模板题和代码帮大家回忆一下字典树是什么样的
维护一个字符串集合,支持两种操作:
I x 向集合中插入一个字符串 x;
Q x 询问一个字符串在集合中出现了多少次。
共有 N 个操作,输入的字符串总长度不超过 105,字符串仅包含小写英文字母。
输入格式
第一行包含整数 N,表示操作数。
接下来 N 行,每行包含一个操作指令,指令为 I x 或 Q x 中的一种。
输出格式
对于每个询问指令 Q x,都要输出一个整数作为结果,表示 x 在集合中出现的次数。
每个结果占一行。
数据范围
1≤N≤2∗104
输入样例:
5
I abc
Q abc
Q ab
I ab
Q ab
输出样例:
1
0
1
题目就是最基础的字典树裸题,直接套板子就可以,
#include <bits/stdc++.h>
using namespace std;
int trie[1000010][26],tot=1,end1[1000010];
void insert(string s)
{
int len=s.size(),p=1;
for(int i=0;i<len;i++)
{
int c=s[i]-'a';
if(trie[p][c]==0)trie[p][c]=++tot;
p=trie[p][c];
}
end1[p]++;
return;
}
int finds(string s)
{
int len=s.size(),p=1;
for(int i=0;i<len;i++)
{
if(trie[p][s[i]-'a']==0)return 0;
p=trie[p][s[i]-'a'];
}
if(end1[p])return p;
}
int main()
{
string s;
char aa;
int n;
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>aa;
if(aa=='I')
{ cin>>s;
insert(s);
}
else{
cin>>s;
int q=finds(s);
cout<<end1[q]<<endl;
}
}
return 0;
}
那么什么是01字典树呢,其实他就是一种特殊的字典树,整个树里只有两种字符,0和1,用来表示二进制的数,我们可以简单总结一下它的性质
1.01字典树是一棵最多 32层的二叉树,其每个节点的两条边分别表示二进制的某一位的值为 0 还是为 1. 将某个路径上边的值连起来就得到一个二进制串。
2.节点个数为 1 的层(最高层)节点的边对应着二进制串的最高位。
那么它可以解决什么问题呢,一般我们用它来解决异或最大值类的问题,因为异或性质是最好相反。所以我们可以优先找和 x二进制的未处理的最高位值不同的边对应的点,这样保证结果最大。如果可以保证高位不同,那么一定会更大,从高层开始往下,可以不同就不同,不能不同就相同,可以保证找到一个数组种两个数异或结果最大。
给一个例题感受一下他的性质
在给定的 N 个整数 A1,A2……AN 中选出两个进行 xor(异或)运算,得到的结果最大是多少?
输入格式
第一行输入一个整数 N。
第二行输入 N 个整数 A1~AN。
输出格式
输出一个整数表示答案。
数据范围
1≤N≤105,
0≤Ai<231
输入样例:
3
1 2 3
输出样例:
3
方法就和上面提到的一样,先构造出字典树,然后让每个位置的数都在字典树上跑一遍,就能找到两两之间异或的最大值了。
#include<bits/stdc++.h>
using namespace std;
int trie[3000010][2],a[3000010],tot=1;
void insert(int a)
{
int p = 0;
for (int i =31;~i; i -- )
{
if (!trie[p][a>>i&1]) trie[p][a>>i&1] = ++tot;
p = trie[p][a>>i&1];
}
}
int finds(int a)
{
int p = 0,ans1=0;
for (int i =31;~i; i -- )
{
int s=a>>i&1;
if (trie[p][!s])
{
p=trie[p][!s];
ans1=ans1*2+!s;
}
else
{
ans1=ans1*2+s;
p=trie[p][s];
}
}
return ans1;
}
int main()
{
int n,x;
scanf("%d", &n);
for(int i=1;i<=n;i++)
{
scanf("%d", &a[i]);
insert(a[i]);
}
int ans=0;
for(int i=1;i<=n;i++)ans=max(ans,a[i]^finds(a[i]));
printf("%d\n",ans);
return 0;
}
带权并查集
并查集相信大家都很熟悉了,就不介绍普通并查集的性质了,我们直接来看带权并查集是干什么的。
用并査集,但是维护多一个数组 d,用来计算当前节点到父节点的 距离,初始化为 0(因为自身到自身的距离为 0)这是带权并查集的一种最基础的应用,就是额外多维护一个信息数组d,用来保存需要的数据,我们一般用它来保存字结点到父节点的距离,用来灵活的解决问题,如果要维护这个距离,那么路径压缩的时候就要注意,把距离改成两段距离之和
int find(int x)
{
if(fa[x]==x){
return x;
}
else {
int t=find(fa[x]);
d[x]+=d[fa[x]];//路径压缩后维护距离
fa[x]=t;
return fa[x];
}
}
直到了可以多维护一个信息,那么这个信息可以用来干什么呢,提到带权并查集,不得不提到2001年Noi中的一道题,食物链,我们先一起看一下题面
动物王国中有三类动物 A,B,C,这三类动物的食物链构成了有趣的环形。
A 吃 B,B 吃 C,C 吃 A。
现有 N 个动物,以 1∼N 编号。
每个动物都是 A,B,C 中的一种,但是我们并不知道它到底是哪一种。
有人用两种说法对这 N 个动物所构成的食物链关系进行描述:
第一种说法是 1 X Y,表示 X 和 Y 是同类。
第二种说法是 2 X Y,表示 X 吃 Y。
此人对 N 个动物,用上述两种说法,一句接一句地说出 K 句话,这 K 句话有的是真的,有的是假的。
当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
当前的话与前面的某些真的话冲突,就是假话;
当前的话中 X 或 Y 比 N 大,就是假话;
当前的话表示 X 吃 X,就是假话。
你的任务是根据给定的 N 和 K 句话,输出假话的总数。
输入格式
第一行是两个整数 N 和 K,以一个空格分隔。
以下 K 行每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中 D 表示说法的种类。
若 D=1,则表示 X 和 Y 是同类。
若 D=2,则表示 X 吃 Y。
输出格式
只有一个整数,表示假话的数目。
数据范围
1≤N≤50000,
0≤K≤100000
输入样例:
100 7
1 101 1
2 1 2
2 2 3
2 3 3
1 1 3
2 3 1
1 5 5
输出样例:
3
动物王国中有三种动物,他们之间可以互相有捕食关系,成了一个环,说很多局话,问有多少是假话,其实就是让我们维护真话的信息,如果后面遇到冲突的。就按照假话去处理。那么我们如何维护这个捕食关系呢,实际上,我们可以建立一个并查集,如果某句话让两个动物联系起来就让他们加入同一个并查集,用“距离”来描述关系、判断关系,所有的距离都以根节点为基准,按照mod类别数(3)分为3类。“距离”:x吃y表示y到x的距离为1. y是第0代,吃y的x是第1代,吃x的是第2代…根节点是第0代三种关系:用点到根节点之间的距离表示其余根节点之间的关系
mod 3 = 1:可以吃根节点
mod 3 = 2:可以被根节点吃
mod 3 = 0:和根节点同类
把集合中所有的点划分为上述三类。每局话,我们先判断是否在同一个集合中,以及是否和之前满足过的关系冲突,来判断是不是假话,如果是真话,我们要把他所表达的信息也维护起来,用来判断后面的话会不会和它发生冲突。具体细节可以参考代码的注释,注意在维护d数组时搞清楚把两个没连接的区间相连需要改变根节点的位置。之后在路径压缩的过程中,其余结点的距离会自动改变为合适的值。
#include<bits/stdc++.h>
using namespace std;
int t,n,m,a,b,ans,fa[100010],d[100010];
void init()
{
for(int i=1;i<=n;i++)
fa[i]=i;
}
int find(int x)
{
if(fa[x]==x){
return x;
}
else {
int t=find(fa[x]);
d[x]+=d[fa[x]];//路径压缩后维护距离
fa[x]=t;
return fa[x];
}
}
int main()
{
ios::sync_with_stdio(false);
cin>>n>>m;
init();
for(int i=1;i<=m;i++)
{
cin>>t>>a>>b;
if(a>n||b>n)ans++;
else
{
int fx=find(a),fy=find(b);
if(t==1)
{
if(fx==fy&&(d[a]-d[b])%3)ans++;//相差是3的倍数,说明是同类。
else if(fx!=fy){
fa[fx]=fy;
d[fx] = d[b] - d[a]; //设fx到fy节点距离是dist,那么(d[x]+dist-d[y])%3应该等于0因为同类,连接后要保证在mod3意义下相等
//然后我们解出来dist应该是 d[y]-d[x](解同余方程)
}
}
else {
if(fx==fy&&(d[a]-d[b]-1)%3)ans++;
else if(fx!=fy)
{
fa[fx]=fy;
d[fx]=d[b]-d[a]+1;
}
}
}
}
cout<<ans<<endl;
return 0;
}
写在后面
四种基础数据结构都可以用来维护很多信息,只有理解他们的原理后,才能灵活的应用他们。本文基本只给出每个数据结构最经典的题目或者模板题,想要更熟悉这些数据结构,大家可以尝试用这些数据结构做更多题目,希望可以帮到大家