单调栈
常见模型:找出每个数左边离它最近的比它大/小的数
int tt = 0;
for (int i = 1; i <= n; i ++ )
{
while (tt && check(stk[tt], i)) tt -- ;
stk[ ++ tt] = i;
}
单调队列
常见模型:找出滑动窗口中的最大值/最小值
int hh = 0, tt = -1;
for (int i = 0; i < n; i ++ )
{
while (hh <= tt && check_out(q[hh])) hh ++ ; // 判断队头是否滑出窗口
while (hh <= tt && check(q[tt], i)) tt -- ;
q[ ++ tt] = i;
}
//h[]数组中存的是下标,方便判断队头是否滑出
KMP算法
KMP(Knuth-Morris-Pratt)算法是一种字符串匹配算法,用于在一个文本字符串中查找特定的模式字符串。该算法的目标是在时间复杂度为 O(n+m) 的情况下完成字符串匹配
可以把整个KMP算法的匹配过程理解为:
匹配到一半匹配不下去了,又不想重新开始匹配,模式串上的指针能否“回跳”到之前某一个位置,从这个位置可以“尝试”和目标串刚才匹配失败的位置接着匹配,且已配的长度尽可能大。
由此理解next[]
的含义:
next[]:给定模式串上的一个下标,返回“最大匹配前缀(串)的最后一个下标”。
此时,KMP的匹配过程应该没问题了,但是如何求next[]
又成了新问题。
不过求next[]
数组,说白了就是“求一个串的最大匹配前后缀”,再说直白点就是给一个串,再随便给个j
下标,我能返回另外一个下标next[j]
,能使p[1,next[j]]==p[j-next[j]+1,j]
。(Ps.这个串,某个位置j
的最前面一坨和最后面一坨能按顺序完全匹配,且这一坨是最长的,可以说是next[]数组不忘初心了)
咱就是说会不会这个求next[]过程,就是某种题型啊,比如说:求一个串的最大匹配前后缀
C++ 代码
#include<iostream>
using namespace std;
const int N=1e5+10,M=1e6+10;
int n,m;
int ne[N];
char p[N],s[M];
int main()
{
// ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
cin>>n>>p+1>>m>>s+1;//yxc习惯下标从1开始
//求next
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;
}
//KMP匹配过程
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];
}
}
}
Trie树
Trie树又叫字典树,是一种用来高效“存储”和“查找”字符串集合的数据结构
对Trie树中son[p][u]
和idx
理解如下:
首先
son[p][u]
其实就是:p
指向的这个节点下面所有字节点,中某个具有特定值为u
的子节点,值为某一时刻的idx
。可以把idx
理解为为所有son[p][u]
节点分配的一个编号,只要son[p][u]
的值不为空,即被分配过idx
,说明存在值为u
的这个节点。而对于某个节点的子节点来说,他们父节点的son[p][u]
值,也就是idx
是固定的,p
就是不停的在这棵Trie树上滑动,查询子节点,得到子节点的son[p][u]
值,p
就滑向查询到的这颗节点,再去探查是否还有目标子节点。直到搜到整个字符串,且cnt[p]
不为0,说明就查询到了目标串了。
#include<iostream>
using namespace std;
const int N=1e5+10;
int n;
int son[N][26],cnt[N];
int idx;
void insert(char str[]){
int p=0;//p=0其实就是把idx=0默认分配给了root这个空节点,所有字符的起始单词都是它的子节点
for(int i=0;str[i];i++){
int u=str[i]-'a';
if(!son[p][u]) son[p][u]=++idx;
p=son[p][u];//滑动p指针
}
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()
{
cin>>n;
while(n--){
char op;
cin>>op;
char s[N];
cin>>s;
if(op=='I'){
insert(s);
}else{
cout<<query(s)<<endl;
}
}
return 0;
}
并查集
用来“快速将两个集合合并”、“询问两个元素是否在同一个集合中”的一种数据结构。
它依托于树形结构,树上每一个点所在集合与根节点所在集合一致,因此如果需要查询某个元素是否位于某个集合,一定要向上追溯到根节点再查看。 构建的具体操作是将,每一个节点,我们都存储一下它的父节点p[x]是谁。向上追溯,如果追溯到x==p[x]也就是父节点的父节点还是自己,那么说明来到根节点了,根节点的p[x]就是这棵树的集合编号。
问题1.如何将判断树根?if(p[x]==x)
只有根节点的编号是自己,其他的节点都是存的父节点编号
问题2.如何求x的集合编号?while(p[x]!=x) x=p[x]
x作为滑动指针向上追溯
问题3.如何合并两个集合?p[x]=y
把其中一棵树插到另外一颗树上,一般将其插入到根节点上就行
问题4.判断两个元素是否在同一个集合中?if(find(x)==find(y))
并查集的路径压缩:
基于问题2,每次查询祖宗节点都需要遍历一下自己所在的这棵树,每次遍历次数为树的高度,时间复杂度很难达到O(1)级别,但通过路径压缩之后,可以极大加速这个过程,使得查询能近乎达到O(1)级别。
//返回x的祖宗节点 + 路径压缩
int find(int x){
if(p[x]!=x) p[x]=find(p[x]);
return p[x];
}
简述一下整个过程,其实就是不断通过find(p[x])递归,直到找到符合该条件if(p[x]==x)的祖宗节点,然后返回该祖宗节点的编号。最后p[x]=find(p[x]);为递归的精妙所在了,先是顺流而上一路查询到祖宗节点,最后退回到上层节点时把下面节点的p[x],也就是父节点都改成了根节点,return p[x];其实一直返回的都是根节点(或者说根节点的编号,因为对于根节点来说二者是一致的)。
并查集的初始化:
for(int i=1;i<=n;i++) p[i]=i;
//根节点的定义就是x==p[x]自己等于自己的编号,开始时每个节点都是一颗独立的树,只有自己一个节点,同时也是根节点
维护整个集合“节点个数”的并查集:
size[find(b)] += size[find(a)];
p[find(a)] = find(b);
//一定要先维护size,再合并并查集,否则find(b)和find(a)找的是同一个节点,没有意义了
当然,使用时不用过多的思考原理,只要记住
find(x)
的含义,就是返回x的祖宗节点。想太多容易钻牛角尖
维护”到祖宗节点距离“的并查集:
int p[N], d[N];
//p[]存储每个点的祖宗节点, d[x]存储x到p[x]的距离
// 返回x的祖宗节点
int find(int x)
{
if (p[x] != x)
{
int u = find(p[x]);
d[x] += d[p[x]];//路径压缩的同时累计到祖宗节点的距离
p[x] = u;
}
return p[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ )
{
p[i] = i;
d[i] = 0;
}
// 合并a和b所在的两个集合:
p[find(a)] = find(b);
d[find(a)] = distance; // 根据具体问题,初始化find(a)的偏移量
堆
堆(Heap)是一种特殊的树形数据结构。它通常用于实现优先级队列、堆排序等算法。堆是一个完全二叉树,堆中的所有层级都被完全填满,只允许最底层的叶节点稍微偏左排列。堆中节点的值具有堆序性质,对于最大堆,每个节点的值都大于或等于其子节点的值;对于最小堆,每个节点的值都小于或等于其子节点的值。
PS.根节点为第一层、第n层节点数:2n-1、总节点数:2n-1
如何手写一个堆?
-
堆的基本功能:
- 插入一个数
- 求集合当中的最小值
- 删除最小值
- 删除任意一个元素(STL中堆不支持)
- 修改任意一个元素(STL中堆不支持)
-
堆的存储
- 鉴于完全二叉树的特点,可以使用一个一维数组存储。
- x节点的左儿子:2x
- x节点的右儿子:2x+1
- 鉴于完全二叉树的特点,可以使用一个一维数组存储。
-
堆的操作
- 一个节点无非向上或者向下变动,由两个操作组合调整到正确位置
- 以下down(i)和up(i)是最朴素的操作,不支持任意删除和修改。
- down()
void down(int u) { int t = u; if (u * 2 <= size && h[u * 2] < h[t]) t = u * 2; if (u * 2 + 1 <= size && h[u * 2 + 1] < h[t]) t = u * 2 + 1; if (u != t) { swap(h[u], h[t]); down(t); } }
- up()
void up(int u) { while (u / 2 && h[u] < h[u / 2]) { swap(h[u], h[u / 2]); u /= 2; } }
- down()
- 还有一种比较复杂的,需要映射多重数据,来达到任意查找和修改的目的
// ph[k]存储第k个插入的点在堆中的位置 // hp[k]存储堆中下标是k的点是第几个插入的 int h[N], ph[N], hp[N], size; // 交换两个点,及其映射关系 void heap_swap(int a, int b) { swap(ph[hp[a]],ph[hp[b]]); swap(hp[a], hp[b]); swap(h[a], h[b]); } 思来想去,越看越复杂,直接看代码才是最清晰的 通过hp[a]、hp[b]知道a,b位置是第几个插入的,然后根据ph[hp[a]]、ph[hp[b]]知道第几个插入的数在堆中的位置,然后交换这个坐标
- 以下down(i)和up(i)是最朴素的操作,不支持任意删除和修改。
- 一个节点无非向上或者向下变动,由两个操作组合调整到正确位置
-
建堆
- 如果采取插入的方式建堆,时间复杂度为nlogn,每次插入操作logn,一共插入n个数据。
- 有一种O(n)的建堆方式
直接按顺序存入堆中,然后从倒数第二层往堆顶挨个执行一遍down()操作就行 for(int i=n/2;i;i--) down(i); 证明的话,算一下总操作数就行,需要处理的节点数*down(x)的操作数
哈希表
哈希表由一个
基于数组
的数据结构和哈希函数组成。它的基本原理是将key
通过哈希函数
计算得到一个索引值
,然后将值存储在该索引对应的位置上。这样,在需要查找、插入或删除值时,可以通过哈希函数快速计算出对应的索引,从而直接访问到目标值,而无需遍历整个数据集。
下面是一些关键特点和操作:
-
快速访问: 哈希表能够以接近常数时间复杂度 O(1) 进行查找、插入和删除操作,即使数据集很大。
-
哈希函数: 哈希函数将键转换为索引,通常将键的特征映射到数组的某个范围内。一个良好的哈希函数应该均匀地分布键的值,尽量减少冲突,以提高性能。
-
冲突处理: 不同的键可能会映射到相同的索引,这就是哈希冲突。常见的解决冲突的方法有两种:开放寻址法和拉链法。
-
开放寻址法: 当发生冲突时,通过一定的方法(如线性探测、二次探测、双重哈希等)在哈希表中寻找下一个可用的位置来存储冲突的键值对。
-
拉链法: 每个哈希桶(索引位置)都是一个链表或其他数据结构,用于存储多个键值对。当出现冲突时,冲突的键值对会以链表的形式连接在一起。
-
容量和负载因子: 容量是指哈希表中可以存储的键值对数量上限,负载因子是已存储键值对数量与容量的比值。适当选择容量和负载因子可以平衡空间利用率和性能。当负载因子超过一定阈值时,哈希表可能需要进行扩容操作。
-
哈希表应用: 哈希表广泛应用于各种场景,例如缓存系统、数据库索引、字典结构等。它能够快速查找、唯一存储和高效更新数据。
@startmindmap
* 哈希表
** 存储结构
*** 开放寻址法
**** 线性探测
*** 拉链法
**** 邻接表
** 字符串前缀哈希
@endmindmap
其中字符串哈希主要作用是将字符串转换为哈希值,从而实现高效的字符串比较和匹配。
算法竞赛中,哈希表常见于将一个较大的定义域,映射到一个较小的值域中。一般来说,模
上目标范围就行,但为了减少冲突,习惯取大于目标范围的最小质数
,为了让所有key同这个数的公约数都为1,从而保证余数的均匀分布,降低冲突率。
哈希函数:一般取大于目标范围的最小质数。
int k=( x % N + N ) % N;
加N模N操作也是非常常见了,如果x >= 0,( x % N + N ) % N == x % N;如果x <= 0,也能通过加N操作把数据限制为大于0的数,确保能映射到0~N这个范围内。
开放寻址法写起来非常简单,不过为了减少冲突一般会多开两到三倍的空间。邻接表写起来稍微麻烦点。
一般哈希:
(1) 拉链法
int h[N], e[N], ne[N], idx;
// 向哈希表中插入一个数
void insert(int x)
{
int k = (x % N + N) % N;
e[idx] = x;
ne[idx] = h[k];
h[k] = 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;
}
(2) 开放寻址法
int h[N];
// 如果x在哈希表中,返回x的下标;如果x不在哈希表中,返回x应该插入的位置
int find(int x)
{
int t = (x % N + N) % N;
while (h[t] != null && h[t] != x)
{
t ++ ;
if (t == N) t = 0;
}
return t;
}
字符串前缀哈希
核心思想:通过预先计算每个前缀的哈希值,来实现快速计算任意子串的哈希值。
可以说是KMP算法的平替了,学过前缀和,这个就很好理解了。
具体做法:将整个字符串看成一个P进制的数,字符串的每一位字母看成这个P进制数的每一位。又因为P进制得到的数可能非常大,多半会溢出,所以需要模上一个Q。
Tips:
- 一般不能把某个字母映射成0,例如:A,AA的前缀哈希都是0,这样会把不同的字符串映射成同一个值。
- 一般做题时默认人品足够好,不存在哈希冲突。
- 一般默认P=131或13331,Q=264。做题经验值,但貌似也能证明这样冲突很小。
- 已知前缀求任意字串时记得
高位对齐
再相减 - 取模Q小技巧,直接用unsigned long long存储哈希值就行,溢出即取模。
typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64
// 初始化
p[0] = 1;
for (int i = 1; i <= n; i ++ )
{
h[i] = h[i - 1] * P + str[i];
p[i] = p[i - 1] * P;
}
// 计算子串 str[l ~ r] 的哈希值
ULL get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];
}
对于取模这件事,第一次学的时候还有些计较,现在看来,貌似所有的哈希都不关心哈希值具体是多少,我们只需要知道,我们能通过这个东西得到何种信息就行。两个哈希值相等就说明两个字符串一样呗(假设无冲突),至于是怎么得到的、取模还是自动溢出还是啥啥啥,不重要。
C++ STL简介
vector, 变长数组,倍增的思想
size() 返回元素个数
empty() 返回是否为空
clear() 清空
front()/back()
push_back()/pop_back()
begin()/end()
[]
支持比较运算,按字典序
pair<int, int>
first, 第一个元素
second, 第二个元素
支持比较运算,以first为第一关键字,以second为第二关键字(字典序)
string,字符串
size()/length() 返回字符串长度
empty()
clear()
substr(起始下标,(子串长度)) 返回子串
c_str() 返回字符串所在字符数组的起始地址
queue, 队列
size()
empty()
push() 向队尾插入一个元素
front() 返回队头元素
back() 返回队尾元素
pop() 弹出队头元素
priority_queue, 优先队列,默认是大根堆
size()
empty()
push() 插入一个元素
top() 返回堆顶元素
pop() 弹出堆顶元素
定义成小根堆的方式:priority_queue<int, vector<int>, greater<int>> q;
stack, 栈
size()
empty()
push() 向栈顶插入一个元素
top() 返回栈顶元素
pop() 弹出栈顶元素
deque, 双端队列
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
insert() 插入一个数
find() 查找一个数
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()
unordered_set, unordered_map, unordered_multiset, unordered_multimap, 哈希表
和上面类似,增删改查的时间复杂度是 O(1)
不支持 lower_bound()/upper_bound(), 迭代器的++,--
bitset, 圧位
bitset<10000> s;
~, &, |, ^
>>, <<
==, !=
[]
count() 返回有多少个1
any() 判断是否至少有一个1
none() 判断是否全为0
set() 把所有位置成1
set(k, v) 将第k位变成v
reset() 把所有位变成0
flip() 等价于~
flip(k) 把第k位取反