「Algorithm」02 数据结构
- 链表与邻接表:树与图的存储
- 栈与队列:单调队列、单调栈
- kmp
- Trie
- 并查集
- 堆
- Hash表
链表与邻接表:树与图的存储
用数组来模拟这些结构,不适用struct和STL。目的是为了提高效率
struct Node
{
int val;
Node * next;
};
Node * p = new Node();
面试题较多,但是new一个节点是比较慢的,做题不需要。
用数组模拟单链表
最常用的邻接表,存储图和树。它们都是用邻接表存储的。
以上是用数组e[N]和数组ne[N]来模拟单链表,如果是不存在,那么取-1。
#include<iostream>
using namespace std;
const int N = 1e5 + 10;
// head 表示头结点的下标
// e[i]是节点i的值
// ne[i] 是节点i的next指针
// idx是一个指针,指向当前用到的点。
int head, e[N], ne[N], idx;
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(int k, int x)
{
e[idx] = x, ne[idx] = ne[k], ne[k] = idx, idx ++;
}
//删除k后面的节点
void remove(int k)
{
ne[k] = ne[ne[k]];
}
int main()
{
int m;
cin >> m;
init();
while ( m --)
{
int k, x;
char op;
cin >> op;
if (op == 'H')
{
cin >> x;
add_to_head(x);
}
else if( op == 'D')
{
cin >> k;
if (k == 0) head = ne[head];
else remove(k - 1);
}else {
cin >> k >> x;
add(k - 1, x);
}
}
for (int i = head; i != -1; i = ne[i])
cout << e[i] << " ";
return 0;
}
用数组模拟双链表
用来优化某些问题。
int l[N], r[N];,让0是head,1是tail
#include<iostream>
using namespace std;
const int N = 100010;
int m;
int e[N], l[N], r[N], idx;
void init()
{
//0表示左端点,1表示右端点
r[0] = 1;
l[1] = 0;
idx = 2;
}
void add(int k, int x)
{
//在k的右边插入x
e[idx] = x;
r[idx] = r[k];
l[idx] = k;
l[r[k]] = idx;
r[k] = idx;
idx ++;
//如果想插入左边,只要add(l[k], x)
}
void remove(int k)
{
r[l[k]] = r[k];
l[r[k]] = l[k];
}
int main()
{
cin >> m;
}
邻接表,就是n个单链表.
栈与队列
栈就是先进后出。
#include<iostream>
using namespace std;
const int N = 100010;
int stk[N], tt;
int main()
{
初始化
//tt = -1;
入栈
//stk[ ++ tt] = x;
出栈
// return stk[tt --];
空
//if (tt >= 0) return true;
//else return false;
栈顶
//stk[tt];
}
实际上,令tt = 0初始化,更简洁。
判空时,只要 if (tt > 0) return true; // if(tt) return true;
else return false;
队列:
int q[N], hh = 0, tt = -1; //hh是队头, tt是队尾。队尾插入,队头弹出
//插入
q[ ++ tt] = x;
//弹出:
return q[hh ++];
if( hh <= tt) not empty;
else empty;
取出队头队尾元素:
return q[hh];
return q[tt];
考验记忆力和毅力(自制力)。
单调栈:
给定一个序列,求序列中的每一个数,离他最近的左边比他小的数。否则为-1。
我们设置一个栈,对于每个x,之前的元素,应该让stk[tt] ≥ x的都出栈。
这样x再入栈,就是一个单调递增的栈序列。而找到最小的,只要找到x入栈前的栈顶即可。
#include<iostream>
using namespace std;
const int N = 1e5 + 10;
int n;
int stk[N], tt;
int main()
{
cin >> n;
for(int i = 0; i < n; ++ i)
{
int x;
cin >> x;
while(tt && stk[tt] >= x) tt --;
if (tt) cout << stk[tt] << " ";
else cout << -1 << " ";
stk[++tt] = x;
}
return 0;
}
//这个算法,每个元素都最多入栈和出栈一次,所以复杂度是O(n)
单调队列
https://www.acwing.com/problem/content/156/滑动窗口
一个序列,在一个窗口内,算出其最大值和最小值,窗口逐渐向右移动。
在一个窗口内,如果右边的元素比左边的元素小,
- 首先考虑暴力怎么做。
- 然后考虑在窗口移动的时候,怎样删除一些无用的元素。
如果不开O2优化,那么数组比STL快一些。
O3优化
#pragma GCC optimize(2)
#include<iostream>
using namespace std;
const int N = 1e6 + 10;
int a[N], q[N];
//q是下标,也就是队列,a只是记录的值a[q[i]]是取下标的值。
int n, k;
int main()
{
cin >> 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;
}
//形成一个单调递增的序列。
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 - q[hh] + 1 > k)
++ hh;
while( hh <= tt && a[q[tt]] <= a[i])
tt --;
q[++tt] = i;
if (i >= k - 1) printf("%d ", a[q[hh]]);
}
}
KMP
- 暴力算法怎么做?
- 如何去优化。
朴素算法:
S[N]→ p[M] for(int i = 1; i ≤ n; ++i)
bool flag = true;
int t = i;
for(int j = 1; j ≤ m; j++, t++)
if (s[t] ≠ p[j])
{
flag = false;
break;
}
KMP解释(以1开始)
next数组表示,next[i] = j,表示p[1, j] = p[ i - j + 1, i]
#include<iostream>
#include<cstdio>
using namespace std;
const int N = 100010, M = 1000010;
int n, m;
char p[N], s[M];
int ne[N];
int main() {
cin >> n >> p + 1 >> m >> s + 1;
//index from 1
// 求Next
for(int i = 2, j = 0; i <= n; ++ i)
{
//每次都比较 j+1与i,到最后之前,可以保证1-j已经是匹配的。除非j = 0
while(j && p[i] != p[ j + 1]) j = ne[j];
if(p[i] == p[j+1]) j++;
ne[i] = j;
//前j个(1-j)的元素与[i-j +1, i]的元素匹配,所以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)//前n个元素已经匹配
{
//匹配成功
printf("%d ", i - n );
j = ne[j];
}
}
return 0;
}
Trie树
高效地存储和查找字符串,集合的数据结构
比如:
abcdef abdef aced bcdf bcff cdaa dcdc
当数量过大,可以使用二进制来表示。比如考虑汉字,直接找其二进制100000101010,每一步仅考虑1和0.
#include<iostream>
#include<cstdio>
using namespace std;
const int N = 100010;
int son[N][26], cnt[N], idx;
//下标是0的点,既是根节点,又是空节点
char str[N];
void insert(char str[])
{
int p = 0;
for(int i = 0; str[i]; ++i){
int u = str[i] - 'a';
if (!son[p][u]) son[p][u] = ++ idx;
p = son[p][u];
}
cnt[p] ++;
}
int query(char str[])
{
int p = 0;
for(int i = 0; str[i]; ++i)
{
int u = str[i] - 'a';
if(!son[p][u]) return 0;
p = son[p][u];
}
return cnt[p];
}
int main()
{
int n;
cin >> n;
while(n--)
{
char op[2];
cin >> op >> str;
if (op[0] == 'I') insert(str);
else cout << query(str) << endl;
}
return 0;
}
并查集
面试和比赛,非常容易出的数据结构。
用来快速的处理:
- 将两个集合合并
- 询问两个元素是否在一个集合当中
belong[x] = a
存储x属于集合a。
近乎O(1)的支持上面的两个操作。
基本原理:每个集合用一棵树来表示,树根的编号就是整个集合的编号,每个节点存储它的父节点,p[x]
表示父节点。
- 问题1: 如何判断树根
p[x] == x
, x就是集合的编号 - 问题2:如何求x的集合编号,
while(p[x] ! = x) x = p[x]
- 问题3:如何合并两个集合:假设px是x的集合的编号,py是y的集合的编号,那么
p[x] = y
并查集的优化:
- (路径压缩), 每次查询其父节点,只要直接指向其祖宗节点。
合并集合
https://www.acwing.com/problem/content/838/
#include<iostream>
#include<cstdio>
using namespace std;
const int N = 1e5 + 10;
int p[N];
int n, m;
int find(int x) //返回x的祖宗节点,加上路径压缩
{
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
int main()
{
cin >> n >> m;
for(int i = 1; i <=n ; ++ i) p[i] = i;
while(m --)
{
char op[2];
//建议读入一个字符,也要使用字符串,自动过滤空格和回车
int a, b;
cin >> op >> a >> b;
if(op[0] == 'M') p[find(a)] = find(b);
else {
if(find(a) == find(b)) puts("Yes");
else puts("No");
}
}
}
连通块中点的数量https://www.acwing.com/problem/content/839/
#include<iostream>
#include<cstdio>
using namespace std;
const int N = 1e5 + 10;
//如何查询每个联通块的数量?维护根节点的size即可。
int p[N], siz[N];
int n, m;
int find(int x) //返回x的祖宗节点,加上路径压缩
{
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
int main()
{
cin >> n >> m;
for(int i = 1; i <=n ; ++ i) {
p[i] = i;
siz[i] = 1;
}
while(m --)
{
char op[5];
//建议读入一个字符,也要使用字符串,自动过滤空格和回车
int a, b;
cin >> op;
if(op[0] == 'C')
{
cin >> a >> b;
if(find(a) != find(b))
siz[find(b)] += siz[find(a)];
p[find(a)] = find(b);
} else if (op[1] == '1')
{
cin >> a >> b;
if(find(a) == find(b)) puts("Yes");
else puts("No");
} else
{
cin >> a;
cout << siz[find(a)] << endl;
}
}
}
堆
如何手写一个堆?
- 插入一个数
- 求集合当中的最小值
- 删除最小值
- 删除任意一个元素
- 修改任意一个元素
堆的基本结构:
是一棵二叉树。是一棵完全二叉树。
以小顶堆为例,每个节点的左右子节点都大于该节点的值。
使用一个一维数组存放堆。
1号点是根节点,x的左儿子是2x,右子节点是2x+1。
操作:
- down(x),往下调整x。每次找这个节点和两个子节点的最小值,然后交换即可。
- up(x),往上调整x。每次往上和父节点比较,需要交换时交换即可。
怎么插入x?
- heap[ ++ idx] = x; up(x);
最小值:
- heap[1]
删除最小值:
- heap[1] = heap[idx]; idx—; down(1);
删除任意元素
- heap[k] = heap[size], size—; down(k); up(k);
修改元素:
- heap[k] = x; down(k); up(k);
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 100010;
int n, m;
int h[N], idx;
void down(int u)
{
int t = u;
if (u * 2 <= idx && h[u * 2] < h[t]) t = u * 2;
if (u * 2 + 1 <= idx && h[u*2 +1] < h[t]) t = u*2 + 1;
if(u!=t){
swap(h[u], h[t]);
down(t);
}
}
void up(int u)
{
while( u / 2 && h[ u/2 ] > h[u])
{
swap(h[u/2], h[u]);
u/=2;
}
}
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; ++ i) scanf("%d", &h[i]);
idx = n;
for(int i = n/2; i ; -- i) down(i);
/*这是建立堆的过程
为什么从n/2开始down?
n/2的子孩子是最后一个元素,是完全二叉树的倒数第二层,所以这样一直递归
的做就好,而且时间复杂度是O(n);
*/
while(m--)
{
printf("%d ", h[1]);
h[1] = h[idx];
idx --;
down(1);
}
return 0;
}
带映射版的堆操作:
https://www.acwing.com/problem/content/841/
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N = 100010;
int n;
int h[N], ph[N], hp[N], idx;
//ph存放插入的第k个元素在堆的下标位置
//hp存放的是堆中第j个元素在ph数组的位置索引。
//ph和hp是互为反函数。
void heap_swap(int a, int b)
{
swap(ph[hp[a]], ph[hp[b]]);
swap(hp[a], hp[b]);
swap(h[a], h[b]);
}
void down(int u)
{
int t = u;
if (u * 2 <= idx && h[u * 2] < h[t]) t = u * 2;
if (u * 2 + 1 <= idx && h[u*2 +1] < h[t]) t = u*2 + 1;
if(u!=t){
heap_swap(u, t);
down(t);
}
}
void up(int u)
{
while( u / 2 && h[ u/2 ] > h[u])
{
heap_swap(u/2, u);
u/=2;
}
}
int main()
{
cin >> n;
int m = 0;
//m是第m个插入的元素。
while(n --)
{
char op[10];
int k, x;
cin >> op;
if (!strcmp(op, "I"))
{
cin >> x;
idx ++;
m ++;
ph[m] = idx;
hp[idx] = m;
h[idx] = x;
up(idx);
}
else if (!strcmp(op, "PM")) cout << h[1] << endl;
else if (!strcmp(op, "DM"))
{
heap_swap(1, idx);
idx --;
down(1);
}
else if(!strcmp(op, "D"))
{
cin >> k;
k = ph[k];
heap_swap(k, idx);
idx --;
down(k), up(k);
} else
{
cin >> k >> x;
k = ph[k];
h[k] = x;
down(k), up(k);
}
}
return 0;
}
find()和cnt()可以改成,get()和cnt()保证不编译错误,compile ERROR。
Hash表
哈希表的存储结构,认为是O(1). 如果要删除,只要开个数组标记一下。
开放寻址法
拉链法
常见情景: 把0-10^9的数映射到0-10^5的数组。
x mod 10 ^ 5 附近的质数。然后处理冲突。100003.
和离散化比较,离散是需要保序的。
常用的字符串的哈希方式。
//拉链法
#include<iostream>
#include<cstring>//有memset
using namespace std;
const int N = 100003;
//取大于10万的最小的质数。
int h[N], e[N], ne[N], idx;
void insert(int x)
{
int k = (x % N + N) % N; //因为有的数是负数。
e[idx] = x;
//e存值
ne[idx] = h[k];
//ne存指针
h[k] = idx;
idx ++;
//插入头部。
}
bool find(int x)
{
int k = (x % N + N) % N;
for(int i = h[k]; i != -1; i = ne[i])
{
if(e[i] == x)
return true;
}
return false;
}
int main(){
int n;
memset(h, -1, sizeof h);
cin >> n;
while(n --)
{
char op[2];
int x;
cin >> op >> x;
if(op[0] == 'I') insert(x);
else {
if (find(x)) puts("Yes");
else puts("No");
}
}
return 0;
}
//开放寻址法
#include<iostream>
#include<cstring>
using namespace std;
//0xf3f3f3f3是10^9级别,很大,用来初始化数组
const int N = 2e5 + 3, null = 0x3f3f3f3f;
//一般取三倍。
//取质数。
int h[N];
int find(int x)
{
int k = (x % N + N) % N;
while(h[k] != null && h[k] != x)
{
k ++;
if(k == N) k = 0;
}
//这个过程一定会停止的,因为总共只有1e5个数字,而N是二倍,所以不担心满;
//结束条件时,h[k] == null 或 h[k] == x
return k;
}
int main(){
int n;
cin >> n;
memset(h, 0x3f, sizeof h);
while(n --)
{
char op[2];
int x;
cin >> op >> x;
int k = find(x);
if (op[0] == 'I')
{
h[k] = x;
}
else
{
if (h[k] != null) puts("Yes");
else puts("No");
}
}
return 0;
}
字符串前缀哈希法。
str = “ABCDEF” h[1] = “A” // 前1个 h[2] = “AB”
即处理前缀的hash,h[0] = 0
如果对字符串进行哈希?当成p进制数,a * p ^ 3 + b * p * 2 ……;再取模Q。
不能把字母映射成0.
那么前缀哈希值有什么用呢?因为我们已经知道 h[L]到h[R]的哈希值
所以求L - R的哈希值,只要 h[R] - h[L - 1] * p ^ {R-L+1};
#include<iostream>
#include<cstring>
using namespace std;
typedef unsigned long long ULL;
const int N = 100010, P = 131;
int n, m;
char str[N];
ULL h[N], p[N];
//p是存放P的n次方
ULL get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];
}
int main(){
cin >> n >> m >> str + 1;
p[0] = 1;
for(int i = 1; i <= n; ++ i)
{
p[i] = p[i - 1] * P;
h[i] = h[i - 1] * P + str[i];
}
while( m --)
{
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
if(get(l1, r1) == get(l2, r2))
{
puts("Yes");
} else {
puts("No");
}
}
return 0;
}
hash算法求两个字符串相同比KMP更好
KMP更适合循环节。
C++STL使用
vector 可变长数组,倍增的思想
string 处理字符串的利器,substr(), c_str()
queue, 队列,进行push(),front(), pop()
priority_queue,优先队列,堆,push(), top(), pop()
stack 栈,Push(), top(), pop()
deque 双端队列
set, map, multiset, multimap 基于平衡二叉树(红黑树)动态维护有序序列
unordered_set, unordered_map, unordered_multiset, unordered_multimap 哈希表
bitset;压位
list用的不多。
vector:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
using namespace std;
int main()
{
vector<int> a(10, 3);
vector<int> b[10]; //定义数组
//定义一个长度为10的,初始值为3的vector
//for(auto x : a) cout << x << endl;
a.size(); //O(1)
a.empty();
a.clear();//清空
/*系统分配空间时,所需时间与空间大小无关,与申请次数有关!!!
所以尽量减少申请空间的次数。
vector最初分配32的空间,然后如果不够,分配64,然后copy过去之前的元素。
申请长度为2 ^ n的数组,那么需要copy(1 + 2 + 2^n-1) = 2^n可以认为是O(1)
申请空间的次数是log(2^n) = n
*/
a.front();
a.back();
a.push_back();
a.pop_back();
begin();
end();
[];
a < b可以判断两个vector的大小,按字典序比。
return 0;
}
下面是模板
C++ STL简介
vector, 变长数组,倍增的思想
size() 返回元素个数
empty() 返回是否为空
clear() 清空
front()/back()
push_back()/pop_back()
begin()/end()
[]
支持比较运算,按字典序
pair<int, int>p;
first, 第一个元素
second, 第二个元素
支持比较运算,以first为第一关键字,以second为第二关键字(字典序)
p = make_pair(10, 2);
p = {10, 2};
pair<int, pair<int, int>>p;存三个
string,字符串
size()/length() 返回字符串长度
empty()
clear()
substr(起始下标,(子串长度)) 返回子串
c_str() 返回字符串所在字符数组的起始地址
用于用printf输出
支持加运算:
string a = "1";
a += "bcd";
queue, 队列
size()
empty()
push() 向队尾插入一个元素
front() 返回队头元素
back() 返回队尾元素
pop() 弹出队头元素
priority_queue, 优先队列,默认是大顶堆
size()
empty()
push() 插入一个元素
top() 返回堆顶元素
pop() 弹出堆顶元素
定义成小顶堆的方式:
priority_queue<int, vector<int>, greater<int>> q;
priority_queue<int> heap;
可以通过插入-x实现小顶堆。
stack, 栈
size()
empty()
push() 向栈顶插入一个元素
top() 返回栈顶元素
pop() 弹出栈顶元素
deque, 双端队列,加强版vector,效率低,尽量不用
size()
empty()
clear()
front()/back()
push_back()/pop_back()
push_front()/pop_front()
begin()/end() 迭代器
[] 支持直接索引
set, map, multiset, multimap, 基于平衡二叉树(红黑树),动态维护有序序列
size()
empty()
clear()
begin()/end()
++, -- 返回前驱和后继,时间复杂度 O(logn)
set/multiset
set不允许重复元素
insert() 插入一个数
find() 查找一个数,返回迭代器,找不到返回end迭代器
count() 返回某一个数的个数
erase()
(1) 输入是一个数x,删除所有x O(k + logn)
(2) 输入一个迭代器,删除这个迭代器
lower_bound()/upper_bound()
lower_bound(x) 返回大于等于x的最小的数的迭代器
upper_bound(x) 返回大于x的最小的数的迭代器
map/multimap
insert() 插入的数是一个pair
erase() 输入的参数是pair或者迭代器
find()
[] 注意multimap不支持此操作。 时间复杂度是 O(logn)
lower_bound()/upper_bound()
map<string ,int> a;
a["yxc"] = 1;
cout << a["yec"] << endl;
unordered_set, unordered_map, unordered_multiset, unordered_multimap, 哈希表
和上面类似,增删改查的时间复杂度是 O(1)
不支持 lower_bound()/upper_bound(), 迭代器的++,--.
bitset, 圧位
是bool数组的1/8。🐂
bitset<10000> s;
~s;
~, &, |, ^
>>, <<
==, !=
[]
count() 返回有多少个1
any() 判断是否至少有一个1
none() 判断是否全为0
set() 把所有位置成1
set(k, v) 将第k位变成v
reset() 把所有位变成0
flip() 等价于~
flip(k) 把第k位取反
作者:yxc
链接:https://www.acwing.com/blog/content/404/
来源:AcWing