常用算法模板之数据结构

单调栈

常见模型:找出每个数左边离它最近的比它大/小的数
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;
              }
          }
          
      • 还有一种比较复杂的,需要映射多重数据,来达到任意查找和修改的目的
        // 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]]知道第几个插入的数在堆中的位置,然后交换这个坐标
        
  • 建堆

    • 如果采取插入的方式建堆,时间复杂度为nlogn,每次插入操作logn,一共插入n个数据。
    • 有一种O(n)的建堆方式
      直接按顺序存入堆中,然后从倒数第二层往堆顶挨个执行一遍down()操作就行
      
      for(int i=n/2;i;i--) down(i);
      
      证明的话,算一下总操作数就行,需要处理的节点数*down(x)的操作数
      

哈希表

哈希表由一个基于数组的数据结构和哈希函数组成。它的基本原理是将key通过哈希函数计算得到一个索引值,然后将值存储在该索引对应的位置上。这样,在需要查找、插入或删除值时,可以通过哈希函数快速计算出对应的索引,从而直接访问到目标值,而无需遍历整个数据集。

下面是一些关键特点和操作:
  1. 快速访问: 哈希表能够以接近常数时间复杂度 O(1) 进行查找、插入和删除操作,即使数据集很大。

  2. 哈希函数: 哈希函数将键转换为索引,通常将键的特征映射到数组的某个范围内。一个良好的哈希函数应该均匀地分布键的值,尽量减少冲突,以提高性能。

  3. 冲突处理: 不同的键可能会映射到相同的索引,这就是哈希冲突。常见的解决冲突的方法有两种:开放寻址法和拉链法。

  4. 开放寻址法: 当发生冲突时,通过一定的方法(如线性探测、二次探测、双重哈希等)在哈希表中寻找下一个可用的位置来存储冲突的键值对。

  5. 拉链法: 每个哈希桶(索引位置)都是一个链表或其他数据结构,用于存储多个键值对。当出现冲突时,冲突的键值对会以链表的形式连接在一起。

  6. 容量和负载因子: 容量是指哈希表中可以存储的键值对数量上限,负载因子是已存储键值对数量与容量的比值。适当选择容量和负载因子可以平衡空间利用率和性能。当负载因子超过一定阈值时,哈希表可能需要进行扩容操作。

  7. 哈希表应用: 哈希表广泛应用于各种场景,例如缓存系统、数据库索引、字典结构等。它能够快速查找、唯一存储和高效更新数据。

@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:
  1. 一般不能把某个字母映射成0,例如:A,AA的前缀哈希都是0,这样会把不同的字符串映射成同一个值。
  2. 一般做题时默认人品足够好,不存在哈希冲突。
  3. 一般默认P=131或13331,Q=264。做题经验值,但貌似也能证明这样冲突很小。
  4. 已知前缀求任意字串时记得高位对齐再相减
  5. 取模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位取反
  • 9
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值