一、前言&前情回顾
前面我们已经学习了一些基础算法。从这一章开始,我们就会进入一些数据结构的学习啦!那么闲言少叙,就让我们直接开始吧!
二、链表(数组模拟)
二、1单链表
关于单链表的结构体模拟,请参看保姆级链表教程!一次搞定链表基础知识!
基本思想:用两个数组,一个数组存储值,另一个数组存储指向的结点编号(空结点的下标用-1表示)。如图:
下面我们来看看如何用代码具体实现吧!注意:以下所说的head头结点并非第一个结点,而是第一个结点前面的一个结点!如图:
3是第一个结点,下标为0,head是3的前面一个结点。这里的空链表指的是head结点指向NULL。
#include<iostream>
using namespace std;
const int N = 1e6;
int head,e[N],ne[N],idx;
//head表示头结点指向的下标
//idx表示当前已经存储到的结点下标(即预备要存进链表里的结点下标)
//初始化,头结点指向NULL
void Init()
{
head = -1;
idx = 0;
}
//将值为x的结点插到头结点后一个位置
void add_to_head(int x)
{
e[idx] = x;
ne[idx] = head;
head = idx;
idx++;
}
//将值为x的结点插到下表为k的结点的后面
void add_to_k(int x,int k)
{
e[idx] = x;
ne[idx] = ne[k];
ne[k] = idx;
idx++;
}
//将下标为k的结点后面的一个结点删除
void remove_k(int k)
{
ne[k] = ne[ne[k]];
}
#include <iostream>
using namespace std;
const int N = 1e6;
int head,e[N],ne[N],idx;
void Init()
{
head = -1;
idx = 0;
}
void add_to_head(int x)
{
e[idx] = x;
ne[idx] = head;
head = idx;
idx++;
}
void add_to_k(int k,int x)
{
//要注意,这里的k指的不是第k个插入的数,而是下标为k!
e[idx] = x;
ne[idx] = ne[k];
ne[k] = idx;
idx++;
}
void delete_k(int k)
{
//让坐标为k的结点(即第k+1个结点)指向后面再后面的结点
ne[k] = ne[ne[k]];
}
int main()
{
int M;
cin>>M;
Init();
while(M--)
{
char op;
cin>>op;
if(op=='H')
{
int x;
cin>>x;
add_to_head(x);
}
else if(op=='D')
{
int k;
cin>>k;
//当k为0时,表示删除头结点。
if(!k)
{
head = ne[head];
}
//第k个插入的数
delete_k(k-1);
}
else
{
int k,x;
cin>>k>>x;
//表面上我们删除了数据,但实际上我们改变的只是ne数组,e数组中仍然保存着值的数据,因此我们第k个插入的数在e中的下标就是k-1.
add_to_k(k-1,x);
}
}
//i! =-1是为了防止访问空结点
//i = ne[i]是让i指向下一个结点
for(int i= head;i!=-1;i=ne[i])
{
cout<<e[i]<<' ';
}
}
二、2双链表
#include <iostream>
using namespace std;
const int N = 1e6;
//l[N]用来存储指向左边的“伪指针”,r[N]用来存储指向右边的“伪指针”
int l[N],r[N],e[N],idx;
//在下标是k的结点的右边插入值为x的结点
void add_to_k_right(int k,int x)
{
e[idx] = x;
r[idx] = r[k];
l[idx] = k;
l[r[k]] = idx;
r[k] = idx;
idx++;
}
//在下标是k的结点的左边插入值为x的结点,相当于在下标为k的结点的左边的结点的右边插入x
void add_to_k_left(int k,int x)
{
//一定要注意,左边未必是k-1,因为结点可能会被取下!所以要用这种表示法
add_to_k_right(l[k],x);
}
//删除下标为k的点
void remove_k(int k)
{
//让左边的右边等于右边,右边的左边等于左边
r[l[k]] = r[k];
l[r[k]] = l[k];
}
int main()
{
int M;
cin>>M;
//这次我们就不用虚拟头结点和虚拟尾结点了,0是头结点,1是尾结点
r[0] = 1;l[1] = 0;idx = 2;
while(M--)
{
string op;
cin>>op;
int k,x;
if(op=="L")
{
cin>>x;
add_to_k_right(0,x);
}
else if(op=="R")
{
cin>>x;
add_to_k_right(l[1],x);
}
else if(op=="D")
{
cin>>k;
//这下第k个插入的数的右边的数下标是k+1了!
remove_k(k+1);
}
else if(op=="IL")
{
cin>>k>>x;
add_to_k_left(k+1,x);
}
else if(op=="IR")
{
cin>>k>>x;
add_to_k_right(k+1,x);
}
}
for(int i = r[0];i!=1;i = r[i])
{
cout<<e[i]<<' ';
}
return 0;
}
三、栈和队列
三、1栈
栈是一种先进后出的数据结构。你可以把它想象成一个圆筒,只能从筒顶出数据,那么就是后面进入的数据先出来。
const int N = 1e6;
//stk是用来存储数据的数组,tt是栈顶的位置
int stk[N],int tt;
//插入x
stk[++tt] = x;
//删除x
tt--;
//判断栈是否为空
if(tt>0);//非空
else; //空
//找到栈顶数据
stk[tt];
例:单调栈
暴力的做法是用两层循环:
for(int i = 0;i<n;i++)
{
for(int j = i-1;j>=0;i--)
{
if(arr[j]<arr[i])
{
cout<<arr[j]<<' ';
break;
}
}
}
我们能不能想办法优化一下呢?我们知道,若a(m)>=a(n)但是m<n,那么a(m)是一定不会作为答案出现的。因为若a(m)满足要求,a(n)自然也满足要求,但是a(n)更近。因此,所有这些构成逆序对的数的大的那一个我们都不用考虑,可以删掉。那么删掉以后我们将剩下的数据存到栈里,就得到一个单调栈。
#include <iostream>
using namespace std;
const int N = 1e6;
int stk[N],tt;
int main()
{
int m;
cin>>m;
for(int i = 0;i<m;i++)
{
int x;
cin>>x;
//如果不符合题意,直接把前面 那个数删了就行
while(tt&&stk[tt]>=x) tt--;
//如果到最后还没有退到0,说明找到了
if(tt) cout<<stk[tt]<<' ';
else cout<<-1<<' ';
//最后别忘了把新的数插入栈
stk[++tt] = x;
}
return 0;
}
三、2队列
队列可以被想象成一个两边开口的圆筒,我们可以在筒的一边放东西,在另一边拿东西,那么队列就是先进先出的。我们在队尾插入元素,在队头弹出元素。
const int N = 1e6;
//hh表示队列头,tt表示队列尾
int q[N];
int hh = -1;
int tt = -1;
//插入x
q[++tt] = x;
//弹出队头元素
hh++;
//判断队列是否为空
if(hh<=tt) not empty
else empty;
//取出队头元素
q[hh];
//取出队尾元素
q[tt];
例:单调队列
队列更新的步骤是:
1.将新的元素插入队尾。
2.将队头的元素弹出。
与上一题类似,如果我们挨个遍历,那么时间复杂度将会是O(Nk)。但是我们可以注意到,对于一个窗口来说,右边的数总是比左边的数后弹出。那么如果右边的数<左边的数,那么左边的数就永远不可能作为最小数输出。(是否似曾相识?这又是逆序对中较大的数没有机会的思想)所以最后我们得到的队列一定是单调上升的。那么单调上升的队列的最小值是什么呢?当然就是队头的元素啦!所以我们只需要取出队头的元素即可。下面让我们一起来看看具体的代码实现:
#include <iostream>
using namespace std;
const int N = 1000010;
//a[N]是原数组,q[N]是队列,队列里存的是下标!
int a[N], q[N];
int main()
{
//把n个数读到a数组里
int n, k;
scanf("%d%d", &n, &k);
for (int i = 0; i < n; i ++ ) scanf("%d", &a[i]);
//hh表示队头,tt表示队尾
int hh = 0, tt = -1;
for (int i = 0; i < n; i ++ )
{
//判断队头是否已经划出窗口。这里i是窗口的终点,那么窗口的起点就是i-k+1,如果它>q[hh],说明队头已经滑出了窗口
//在判断之前,还要判断队列是否为空。若hh<=tt则不为空
//为什么这里用if不用while呢?因为我们的窗口只能滑动一次,每次队列中最多只有一个不在窗口内
if (hh <= tt && i - k + 1 > q[hh]) hh ++ ;
//如果a[q[tt]]>=a[i]那么它一定不是答案,就出队
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;
}
// 作者:yxc
// 链接:https://www.acwing.com/activity/content/code/content/43107/
// 来源:AcWing
// 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
四、kmp算法
kmp算法是应用于字符串匹配的经典算法。请看以下例题:
我们很容易想到一种暴力求解的算法,那就是用两个“伪指针”i、j分别指向主串和模板串,逐个比较主串和子串中的字母,若不相同,则让i++,再让j从0开始到i逐个比较。但是这样会造成很多重复的比较,由于我们在前一次的比较中已经掌握了一些信息,我们能不能利用这些信息对下一次比较做出优化呢?这时我们可以利用next数组。我们先不用理会next数组是如何得到的,只要清楚它记录了下一次比较时我们可以跳过的字符个数就行啦。例如:
第一次出现不匹配的字符是C,我们观察它前一个字符next值,就知道在下一次匹配时子串可以跳过2个字符,从第二个A开始匹配。
那么这种神奇的next数组是如何生成的呢?我们直接给出结论:next的值是到当前字符范围内最长公共前后缀的长度。例如在上面的例子中,第一个A没有公共前后缀,因此next值为0;第一个B没有公共前后缀,next值也为0;第二个A有公共前后缀A,因此next值为1……那么,难道计算next值时也要用暴力遍历的方法么?并不,我们可以利用递推。假定我们已经知道最后一个字符的next值,那么若下一个字符可以与已有最长公共前后缀构成新的公共前后缀,那么新的next值让前一个+1即可;如果不能,我们可以通过分析前缀的情况来得知后缀的情况,因为前后缀是公共的。这时,我们只要访问对应的数组下标即可。
那么下面就让我们一起来看看具体的代码实现吧!
#include <iostream>
using namespace std;
const int N = 100010, M = 1000010;
int n, m;
int ne[N];
char s[M], p[N];
int main()
{
cin >> n >> p + 1 >> m >> s + 1;
for (int i = 2, j = 0; i <= n; i ++ )
{
while (j && p[i] != p[j + 1]) j = ne[j];
if (p[i] == p[j + 1]) j ++ ;
ne[i] = j;
}
for (int i = 1, j = 0; i <= m; i ++ )
{
while (j && s[i] != p[j + 1]) j = ne[j];
if (s[i] == p[j + 1]) j ++ ;
if (j == n)
{
printf("%d ", i - n);
j = ne[j];
}
}
return 0;
}
// 作者:yxc
// 链接:https://www.acwing.com/activity/content/code/content/43108/
// 来源:AcWing
// 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
如果读者想了解具体的证明过程,不妨看看这个视频:
最浅显易懂的kmp算法讲解!
相信你一定会感到大有收获的。
好啦,以上就是本篇文章的全部内容。如果你感觉对自己有帮助的话,请三连支持一下作者,你们的支持是我不断更新的最大动力!