问题来源 : ACWing
https://www.acwing.com/blog/content/277/
为什么要使用数组来模拟实现这些数据结构?
可能存在一种情况:以单链表
为例,题目要求完成一个链表的操作,但是他的数据范围很大,也就是要完成很多节点的插入操作,那么这时候new
大量的节点就会有超时的风险,所以说就需要使用数组来模拟实现一个静态的数据结构
。
但是,这些静态的数据结构知识实现的方式不同,在一些性能方面,还是和链式结构是相同的。比如说,中间插入
一个元素,还是要先找到前一个元素,这样的时间复杂度还是O(n)
单链表
还是要注意一点,这里的中间插入和删除
,他并不是对链表抽象后的数据中第K个,而是我们插入顺序中,第K个插入的节点,所以说使用数组才可以O(1)
。
#include <iostream>
using namespace std;
const int N = 1e5 + 10;
// head 头结点的下标
// e[i] 表示 i 号节点的值
// ne[i] 表示 i 号节点的下一个节点的下标
int head,e[N],ne[N];
int idx; // 当前以经使用的节点数
int n;
void Init() {
head = -1;
idx = 0;
}
void push_front(const int x) {
int p = idx++;
e[p] = x;
ne[p] = head;
head = p;
}
// 中间插
void insert(const int k,const int x) {
if(k >= idx) return ;
int p = idx++;
e[p] = x;
ne[p] = ne[k];
ne[k] = p;
}
// 删除
void erase(const int k) {
if(k == -1) { // 删除头结点
head = ne[head];
} else {
ne[k] = ne[ne[k]]; // 删除中间节点
}
}
int main() {
Init();
cin >> n;
char oper;
int k,x;
for(int i = 0; i < n; i++) {
cin >> oper;
if(oper == 'H') {
cin >> x;
push_front(x); // 头插
} else if(oper == 'D') {
cin >> k;
erase(k - 1); // 删除
} else if(oper == 'I') {
cin >> k >> x;
insert(k - 1,x); // 中间插入
}
}
int p = head;
while(p != -1) {
cout << e[p] << " ";
p = ne[p];
}
return 0;
}
双链表
跟我们正常链表的思维差不多,维护一个pre数组
,表示当前节点的前一个节点;再维护一个ne数组
,表示当前节点的下一个节点。
然后依次实现头插
,尾插
,中间删
,以及中间插入
。(头插和尾插也可以使用中间插入转换一下),然后实现一个带头结点的双向循环链表
因为在这里,我们使用头结点做了一个哨兵位
,将0号下标的位置占据了,所以第K次
插入的数据即为K下标
的数,也就是Idx == K
#include <iostream>
#include <string>
using namespace std;
const int N = 1e5 + 10;
int head,e[N],pre[N],ne[N],idx;
void init() {
head = idx, e[idx] = -1,pre[idx] = idx,ne[idx] = idx, idx++;
}
// 头插
void push_front(const int x) {
e[idx] = x, pre[ne[head]] = idx, ne[idx] = ne[head], pre[idx] = head, ne[head] = idx++;
}
// 尾插
void push_back(const int x) {
e[idx] = x, ne[pre[head]] = idx, pre[idx] = pre[head], ne[idx] = head, pre[head] = idx++;
}
// 中间删
void erase(const int k) {
pre[ne[k]] = pre[k], ne[pre[k]] = ne[k];
}
// 第 k 个数的后面插入 x
void insert(const int k,const int x) {
e[idx] = x, pre[ne[k]] = idx, ne[idx] = ne[k], pre[idx] = k, ne[k] = idx++;
}
int main() {
int n;
cin >> n;
init();
while(n --) {
string str;
cin >> str;
int k,x;
if(str == "R") {
cin >> x;
push_back(x);
} else if(str == "D") {
cin >> k;
erase(k);
} else if(str == "L") {
cin >> x;
push_front(x);
} else if(str == "IL") {
cin >> k >> x;
insert(pre[k],x);
} else if(str == "IR") {
cin >> k >> x;
insert(k,x);
}
}
for(int i = ne[head]; i != head; i = ne[i]) cout << e[i] << " ";
return 0;
}
栈
栈的特点:
后进先出
#include <iostream>
#include <string>
using namespace std;
const int N = 1e5 + 10;
int f[N],top;
void push(const int x) {
f[top++] = x;
}
void pop() {
top--;
}
int query() {
return f[top - 1];
}
string empty() {
return top == 0 ? "YES" : "NO";
}
int main() {
int n;
cin >> n;
int x;
while(n --) {
string str;
cin >> str;
if(str == "push") {
cin >> x;
push(x);
} else if(str == "pop") {
pop();
} else if(str == "query") {
cout << query() << endl;
} else if(str == "empty") {
cout << empty() << endl;
}
}
return 0;
}
队列
队列的特点:
先进先出
#include <iostream>
#include <string>
using namespace std;
const int N = 1e5 + 10;
int f[N],front,tail;
void push(const int x) {
f[tail++] = x;
}
void pop() {
front++;
}
int query() {
return f[front];
}
string empty() {
return front == tail ? "YES" : "NO";
}
int main() {
int n;
cin >> n;
int x;
while(n --) {
string str;
cin >> str;
if(str == "push") {
cin >> x;
push(x);
} else if(str == "pop") {
pop();
} else if(str == "query") {
cout << query() << endl;
} else if(str == "empty") {
cout << empty() << endl;
}
}
return 0;
}
单调栈
大体思路就是,当插入一个数的时候,他肯定存在两种情况:
- 在这个数之前,存在比自己小的数,那么结果肯定是这个第一个比自己小的数的大小
- 没有比自己小的数,那么结果就是-1
最朴素的做法,就是每次插入一个数的时候,从后向前
暴力搜索前面所有的情况,找到第一个符合的数,然后退出。
那么可以思考一个问题,当我们插入一个数的时候,如果在左边第一个比自己小的数中间的区间中,这些比自己大的数还可能被使用吗?
#include <iostream>
#include <stack>
using namespace std;
int main() {
stack<int> st;
int n;
cin >> n;
while(n --) {
int num;
cin >> num;
int ans = -1;
while(!st.empty()) {
int t = st.top();
if(t >= num) st.pop(); // 栈顶元素比当前元素大,进行压缩
else {
ans = t; // 栈顶元素即为第一个比自己小的,返回结果
break;
}
}
st.push(num);
cout << ans << " ";
}
return 0;
}
滑动窗口,单调队列
整体的思想就是维护一个K大小的窗口,然后每次求这个窗口中的最大值,或者最小值。
在朴素的做法中,可以每次遍历K个窗口中的每个数,求出这样的一个最大值或者最小值。但是不妨思考下,这样的时间复杂度为O(n * k)
,这在本题中 n
是1e6
,而 k > 0 && k < n
来说是很恐怖的一个时间复杂度了。
本题是一个基于滑动窗口的单调队列问题,也是一个经典题。
思路就是,每次在队列中维护一个最长为K
区间的窗口,每一次确保窗口的最小值或者最大值在窗口的左侧,也就是队头位置。
那么对于这组数据,对于求最大值和求最小值,我们就可以对原本K大小的固定窗口进行一次压缩,确保窗口的开始位置一定是当前窗口最值的一个下标
以求当前窗口的最大值为例:
- 当我们求最大值的时候,我们得确保插入一个数后,当前窗口的大小为
[1 - k]
。 - 插入一个数,得先去掉前面比自己小的数。(这里可以想象为一个大饼,把他在后面一放,就会把前面比自己小的饼都压碎)
- 实际上,因为当前窗口都是合法大小的,我们插入一个新的数据后,可能会去掉一些元素,可以细想一下,从这些元素到当前位置的这个
区间M
中,一定符合这个M <= K
,那么这个M区间
中的最大值就是新插入的这个数。
#include <iostream>
using namespace std;
const int N = 1e6 + 10;
int f[N],q[N];
int n,k;
int main() {
scanf("%d%d",&n,&k);
for(int i = 0; i < n; i++) scanf("%d",&f[i]);
int hh = 0, tt = -1;
// 当前窗口最小值
for(int i = 0; i < n; i++) {
// 判断队列中是否有 k 个元素
if(hh <= tt && i - k + 1 > q[hh]) hh++;
// 压缩掉前面比自己大的数
while(hh <= tt && f[q[tt]] >= f[i]) tt--;
q[++tt] = i;
if(i >= k - 1) printf("%d ",f[q[hh]]);
}
printf("\n");
// 当前窗口最大值
hh = 0, tt = -1;
for(int i = 0; i < n; i++) {
if(hh <= tt && i - k + 1 > q[hh]) hh++;
while(hh <= tt && f[q[tt]] <= f[i]) tt--;
q[++tt] = i;
if(i >= k - 1) printf("%d ",f[q[hh]]);
}
return 0;
}
KMP算法
在朴素的做法中,一般都是这样想的
for(int i = 1; i <= n; i++) {
bool flag = true;
int le = i;
for(int j = 1; j <= m; j++) {
if(le >= n) flag = false,break;
if(s[le++] != t[j]) flag = false,break;
}
if(flag == true) return true;
}
return false;
但是由于每一次的不匹配,都只会对原串向后跳转1位,然后继续匹配待匹配的字符串,这样的时间复杂度为O(n * m)
,很高了。
KMP算法的设计思想:
以每一个点为终点的后缀,和那个点的前缀相等,只有这个相等的长度最大,就可以确保每次跳跃的位置越多,重复判断的长度越少。
#include <iostream>
using namespace std;
const int N = 1e5 + 10, M = 1e6 + 10;
int n,m;
char p[N],s[M];
int ne[N];
int main() {
cin >> n >> p + 1 >> m >> s + 1; // 字符串下标从1开始
// 构造next数组的过程
// ne[1] = 0
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;
}