算法模板之数据结构

1.单链表(数组版)

有几个注意的点:

  1. 凡是用数组模拟数据结构的,其实就要宏观的想象成指针。比如ne[k] = ne[idx]就相当于
    k->next = idx
  2. idx其实就是当前的节点数,曾经创建过多少节点的意思。注意一点:如果删除了以前的节点,idx是不会–的。空间浪费就浪费了,因为如果要像数组一样挪动数组删除,时间复杂度是O(N).太慢了。
  3. 这里实现的insert都是右插,因为左插在单链表里面是O(N),太慢了,没有意义。
  4. head一开始指向-1,代表head一开始指向空。由于头节点不用存数据,这里的head就相当于一个指针。
#include <iostream>

using namespace std;

const int N = 100010;

int e[N],ne[N],head = -1,idx;

int m;

void push_front(int x)
{
    e[idx] = x;
    ne[idx] = head;
    head = idx;
    idx++;
}
void insert(int k,int x)
{
    e[idx] = x;
    ne[idx] = ne[k];
    ne[k] = idx++;
}
void remove(int k)
{
    ne[k] = ne[ne[k]];
}
int main()
{
    cin>>m;
    while(m--)
    {
        string op;
        cin>>op;
        if(op == "H")
        {
            int x;
            cin>>x;
            push_front(x);
        }
        else if(op == "I")
        {
            int k,x;
            cin>>k>>x;
            insert(k-1,x);
        }
        else
        {
            int k;
            cin>>k;
            if(k == 0) head = ne[head];
            remove(k-1);
        }
    }
    for(int i = head;i != -1; i = ne[i]) cout<<e[i]<<" ";
}

2.双链表(数组版)

几个点:

  1. 双链表一开始建立了两个节点0和1.分别代表头尾节点。因此idx从2开始。
  2. 头插相当于在0节点右边插入,尾插相当于在1节点左边插入。
  3. 记得要初始化!!!
  4. 打印链表(数组形式)的通用方式其实和普通版是一样的。
    在单链表里面是走到空停止
    i != -1
    在双链表里面是走到尾节点
    i != 1
#include <iostream>

using namespace std;

const int N = 100010;

int l[N],r[N],e[N],idx,m;

void init()
{
    r[0] = 1;
    l[1] = 0;
    idx = 2;
}
void insert(int k,int x)//右插
{
    e[idx] = x;
    r[idx] = r[k];
    l[idx] = k;
    l[r[k]] = idx;
    r[k] = idx;
    idx++;
}

void remove(int k)//删除当前节点
{
    r[l[k]] = r[k];
    l[r[k]] = l[k];
}
int main()
{
    cin>>m;
    init();
    while(m--)
    {
        string op;
        cin>>op;
        if(op == "R")
        {
            int x;
            cin>>x;
            insert(l[1],x);
        }
        else if(op == "D")
        {
            int k;
            cin>>k;
            remove(k+1);
        }
        else if(op == "L")
        {
            int x;
            cin>>x;
            insert(0,x);
        }
        else if(op == "IL")
        {
            int k,x;
            cin>>k>>x;
            insert(l[k+1],x);
        }
        else
        {
            int k,x;
            cin>>k>>x;
            insert(k+1,x);
        }
    }
    for(int i  = r[0]; i != 1; i = r[i]) cout<<e[i]<<" ";
}

3.栈和队列

栈:
栈用数组写是常规操作了,没啥好说的。

注意一点就好:这个栈顶可以写成和元素相同位置的版本,也可以写成栈顶比元素大1的版本。都一样。
这个是栈顶下标比元素大1的版本。
两种写法的区别就是前置++还是后置++的区别

#include <iostream>

using namespace std;

const int N = 100010;

int st[N],tt;//tt代表top的意识
int m;

void push(int x)
{
    st[tt++] = x;
}
void pop()
{
    tt--;
}
int top()
{
    return st[tt-1];
}
bool empty()
{
    return tt==0;
}
int main()
{
    cin>>m;
    while(m--)
    {
        string op;
        cin>>op;
        if(op=="push")
        {
            int x;
            cin>>x;
            push(x);
        }
        else if(op=="pop")
        {
            pop();
        }
        else if(op == "query")
        {
            cout<<top()<<endl;
        }
        else if(op=="empty")
        {
            if(empty()) cout<<"YES"<<endl;
            else cout<<"NO"<<endl;
        }
    }
}

队列:
队列也可以写成两个版本。也是前置++和后置++的区别
这个是后置++的版本。

#include <iostream>

using namespace std;

const int N = 100010;

int q[N],ff,rr;//front和rear

int m;

void push(int x)
{
    q[rr++] = x;
}
void pop()
{
    ff++;
}
bool empty()
{
    return ff==rr;
}
int front()
{
    return q[ff];
}
int main()
{
    cin>>m;
    while(m--)
    {
        string op;
        cin>>op;
        if(op=="push")
        {
            int x;
            cin>>x;
            push(x);
        }
        else if(op=="pop")
        {
            pop();
        }
        else if(op == "empty")
        {
            if(empty()) cout<<"YES"<<endl;
            else cout<<"NO"<<endl;
        }
        else
        {
            cout<<front()<<endl;
        }
    }
}

4.单调栈和单调队列

这两个算法应用范围都比较窄。一般题型都差不多。

单调栈

单调栈题目:
给定一个长度为 N 的整数数列,输出每个数左边第一个比它小的数,如果不存在则输出 −1。

对于具有单调性的东西,一般做法都是先想一个暴力做法,然后再想一想它是否具有单调性,有单调性就可以进行优化。
在这里插入图片描述
对第二个循环进行优化:单调栈
在这里插入图片描述

给一个比较直观好理解的版本,但是代码不够优美

#include <iostream>

using namespace std;

const int N = 100010;

int st[N],tt;

int main()
{
    int m;
    cin>>m;
    for(int i = 0;i < m;i++)
    {
        int x;
        cin>>x;
        while(tt != 0 && x<=st[tt-1])//如果x比栈顶的元素要小,前面的都没用了,pop掉
        {
            tt--;
        }
        if(tt == 0)//如果栈为空,就证明它前面没有元素了。
        {
            cout<<"-1"<<" ";
        }
        else if(tt != 0 && x>st[tt-1])//栈顶那个一定是答案
        {
            cout<<st[tt-1]<<" ";
        }
        st[tt++] = x;//每次都要把结果放进去
    }
}

代码优美一点的写法

#include <iostream>
using namespace std;

const int N = 100010;

int st[N],tt;

int main()
{
    int m;
    cin>>m;
    
    for(int i = 0;i<m;i++)
    {
        int x;
        cin>>x;
        while(tt && st[tt-1]>=x) tt--;
        if(tt) cout<<st[tt-1]<<" ";
        else cout<<"-1"<<" ";
        
        st[tt++] = x;
    }
}

用几何上的语言讲,可以画个折线图。
在这里插入图片描述

单调队列

单调队列比单调栈要抽象很多。(至少我这么觉得)
单调队列常见模型是:找出滑动窗口中的最大值/最小值
假设滑动窗口的大小是3
还是一样的,先想暴力做法。
在这里插入图片描述
优化一下第二层循环。
总体思路就是:前面比新元素要大的,肯定不是滑动窗口里最小的,因此可以直接删除了,没有意义。注意:这里的队列是存放数组a的下标的,因为要确定滑动窗口的起点。如果存值的话,你就不知道现在走到哪里了
在这里插入图片描述

注意点:删除的时候要从后往前删,不能从头往后删。有可能出现3 5 4这种情况,4
比5小,因此5没用了,但是3却比4小,答案仍然是3.

#include <iostream>

using namespace std;

const int N = 1000010;

int a[N],q[N],ff,rr;

int n,m;

int main()
{
    cin>>n>>m;
    for(int i = 0;i<n;i++) cin>>a[i];
    for(int i = 0;i<n;i++)
    {
        while(ff < rr && i-m+1>q[ff]) ff++;//ff<rr代表队列不为空
        while(ff < rr && a[i]<a[q[rr-1]]) rr--;//ff<rr代表队列不为空,这里是从后往前删
        q[rr++] = i;
        if(i-m+1>=0) cout<<a[q[ff]]<<" ";
    }
    cout<<endl;
    ff = 0,rr = 0;
    for(int i = 0;i<n;i++)
    {
        while(ff < rr && i-m+1>q[ff]) ff++;
        while(ff < rr && a[i]>a[q[rr-1]]) rr--;
        q[rr++] = i;
        if(i-m+1>=0) cout<<a[q[ff]]<<" ";
    }
}

队头一定是最小值或者最大值的下标。因为不符合要求的都被删完了

5.KMP

下面这种写法是从1开始的写法,不是普遍的从0开始的写法。从1开始的写法好处在于没那么多边界条件处理.

#include <iostream>

using namespace std;

const int N = 100010,M = 1000010;

char s[M],p[N];
int ne[N];
int n,m;

int main()
{
    cin>>n>>p+1>>m>>s+1;//下标从一开始
    //处理next数组
    for(int i = 2,j = 0;i<=n;i++)//i是代表子串的长度,从2开始,因为2才可能有前缀等于后缀的可能性
    {
        while(j && p[i] != p[j+1]) j = ne[j];//如果前缀不等于后缀,前缀往后退。注意是前缀。
        if(p[i] == p[j+1]) j++;
        ne[i] = j;//每一个不同长度的子串的ne都被储存下来了
    }
    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)
        {
            j = ne[j];//如果题目要求找到一种之后要继续找,那就继续找。不用就直接break就好了
            cout<<i-n<<" ";//i-n就是子串的起始位置
        }
    }
}

在这里插入图片描述
画个图来大概解释一下
在这里插入图片描述
这个往后退的值就是next数组存放的值。next数组具体定义是什么呢?
模式串的每一个子串的前缀子串和后缀字串相等的长度。
对 p = “abcab”

p a b c a b
下标 1 2 3 4 5
next[ ] 0 0 0 1 2
对next[ 1 ] :前缀 = 空集—————后缀 = 空集—————next[ 1 ] = 0;

对next[ 2 ] :前缀 = { a }—————后缀 = { b }—————next[ 2 ] = 0;

对next[ 3 ] :前缀 = { a , ab }—————后缀 = { c , bc}—————next[ 3 ] = 0;

对next[ 4 ] :前缀 = { a , ab , abc }—————后缀 = { a . ca , bca }—————next[ 4 ] = 1;

对next[ 5 ] :前缀 = { a , ab , abc , abca }——后缀 = { b , ab , cab , bcab}—next[ 5 ] = 2;
非常重要的一点:前缀和后缀是连续的,不能中途跳跃。且起点一定是头和尾的字符。
比如说,对于next[5],后缀不能出现ac这种串,因为它不是从尾字符开始的,尾字符是b

而j往后退的值就是next数组存放的值。这一点可以进行证明。
在这里插入图片描述
因此可以得到上面的代码,确实有点抽象,结合着代码看会更好理解。

6.Trie树

在这里插入图片描述
Trie树利用多个字符串的公共前缀来节省存储空间,当我们把大量字符串存储到一棵trie树上时,我们可以快速得到某些字符串的公共前缀。。

#include <iostream>

using namespace std;

const int N = 100010;

//son[N][26] 存储的是节点的子节点的idx值。idx代表这个节点是第几个创建的节点
int son[N][26],cnt[N*26],idx;//首先son[N]代表这棵树最大高度可以是N,因为字符串长度最长是N。cnt代表的是以idx为x结尾的字符串出现的次数是多少。cnt开的空间感觉要和idx个数一样多才行。

int n;


void insert(string s)
{
    int p = 0;//一开始p指向根节点,parents
    for(int i = 0; i< s.size();i++)
    {
        int u = s[i]-'a';//这个是为父亲节点选择哪一个子节点作准备。比如如果这个字符是a就代表是父亲节点指向第0个子节点,依次类推
        if(son[p][u]==0) son[p][u] = ++idx;//如果父亲节点现在指向的子节点还没有被创建。那就创建一个新的节点并指向它
        p = son[p][u];//然后让p指向下一个节点,继续迭代下去。(p本来指向父亲,执行完这句话就走到它的子节点了)
    }
    cnt[p]++;//循环走完代表p已经指向最后一个节点了,以当前idx为结尾的字符串出现次数+1
}
int query(string s)
{
    int p = 0;
    for(int i = 0; i < s.size(); i++)
    {
        int u = s[i]-'a';
        if(son[p][u]==0) return 0;//如果没有这个字符的节点,证明没有这个字符串
        p = son[p][u];
    }
    return cnt[p];
}
int main()
{
    cin>>n;
    while(n--)
    {
        char op;
        cin>>op;
        if(op=='I')
        {
            string s;
            cin>>s;
            insert(s);
        }
        else
        {
            string s;
            cin>>s;
            cout<<query(s)<<endl;;
        }
    }
}

7.并查集

并查集的基础操作就是找祖先节点以及路径压缩操作。这两个功能用find函数就可以解决。剩下的要根据题目要求要维护一下另外的东西,如:size,路径长度等等。

先给一个朴素的并查集

路径压缩指:每次在查找子节点的祖先节点的时候,顺便让他指向祖先节点。这样下次查找祖先节点的效率就很高了。

这个find函数写的很优美,它可以完成两个工作。1是路径压缩,2是寻找祖先节点

#include <iostream>

using namespace std;

const int N = 100010;

int p[N];

int find(int x)//find函数,可以做到找到一个节点的祖先节点和路径压缩。
{
    if(p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int main()
{
    int n,m;
    cin>>n>>m;
    for(int i = 1; i <= n; i++) p[i] = i;//并查集初始化,一开始每一个数字都是一个集合
    while(m--)
    {
        string op;
        cin>>op;
        int a,b;
        cin>>a>>b;
        if(op == "M")
        {
            p[find(a)] = find(b);//合并集合就是让自己的祖先节点指向另一个集合的祖先节点
        }
        else
        {
            if(find(a) == find(b)) cout<<"Yes"<<endl;//如果我们两个节点拥有相同的祖先节点
            else cout<<"No"<<endl;
        }
    }
}

下面写一个维护集合大小的并查集

#include <iostream>

using namespace std;

const int N = 100010;

int p[N],sz[N];//维护每一个集合的大小

int find(int a)
{
    if(p[a] != a) p[a] = find(p[a]);
    return p[a];
}

int main()
{
    int n,m;
    cin>>n>>m;
    for(int i = 1; i <= n ; i++) p[i] = i,sz[i] = 1;//一开始每个集合的大小都是1
    
    
    while(m--)
    {
       string op;
       cin>>op;
       if(op == "C")
       {
           int a,b;
           cin>>a>>b;
           if(find(a) == find(b)) continue;//连过就不用再连了
           sz[find(b)] += sz[find(a)];//更新一下集合的大小
           p[find(a)] = find(b);
       }
       else if( op == "Q1")
       {
           int a,b;
           cin>>a>>b;
           if(find(a) == find(b)) cout<<"Yes"<<endl;
           else cout<<"No"<<endl;
       }
       else
       {
           int a;
           cin>>a;
           cout<<sz[find(a)]<<endl;
       }
    }
}

8.堆

这里堆也是有两种写法的,一种从下标0开始存储,这种是从下标1开始存储的。
对应的公式稍微有点不同
lchild = parents * 2; rchild = parents * 2 + 1;//下标为1的版本
lchild = parents * 2 + 1; rchild = parents * 2 + 2;//下标为0的版本
i = n / 2开始建堆//下标为1的版本 i = (n - 1 - 1) / 2开始建堆//下标为2的版本
这里说多一句,建堆不只是可以down建堆,还可以up建堆。一直push插入就好了。只不过复杂度是O(NlogN),因此别用push建堆了。

#include <iostream>

using namespace std;

const int N = 100010;

int h[N],sz;

void down(int x)
{
    int t  = x;
    if(t * 2 <= sz && h[t * 2] < h[x]) x = t * 2;
    if(t * 2 + 1 <= sz && h[t * 2 + 1] < h[x]) x = t * 2 + 1;
    if(t != x)
    {
        swap(h[t], h[x]);//由于这里只是交换了值,没有交换下标,因此这里不用迭代
        down(x);
    }
}

int main()
{
    int n,m;
    cin>>n>>m;
    sz = n;
    for(int i = 1;i <= n; i++) cin>>h[i];
    
    for(int i = n / 2; i > 0; i --) down(i);//建堆
    
    while(m--)
    {
        int min = h[1];
        cout<<min<<" ";
        h[1] = h[sz];
        sz--;
        down(1);
    }
}

上面的堆只能实现头插头删,不能实现任意位置的删除和修改。要实现任意位置的删除和修改就要抽象的多了。这种堆在后面的算法中可以用的到,不过用的少。

注意点:

  1. 向上调整的时候并不用去找一个最小的孩子。因为在插入的时候前面都是完整的小堆。如果插入的节点比父亲节点要小,那它也一定会比它的兄弟节点小。
  2. ph数组和hp数组其实是一种映射关系。ph代表p映射到h(pointer—>heap),hp则相反。
    在任意位置修改和删除节点的时候,题目给了一个第k个插入的节点给我们。其实这就是ph的映射关系。这也是ph存在的意义。hp数组是存储每一个节点的插入顺序的,这就是hp数组存在的意义。
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 100010;
int h[N],hp[N],ph[N],sz;


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 x)
{
    int t = x;
    if(t * 2 <= sz && h[t * 2] < h[x]) x = t * 2;
    if(t * 2 + 1 <= sz && h[t * 2 + 1] < h[x]) x = t * 2 + 1;
    if(t != x)
    {
        heap_swap(t, x);//给两个下标,要把这两个节点全部信息都交换
        down(x);//交换的是节点,但是下标还是没变的,仔细想想下标怎么会变呢?
    }
}

void up(int x)
{
   //向上调整没有必要找出比较小的那个孩子。因为push的时候,前面都已经符合了小堆的概念。如果要插入的元素小于父节点,那么它也一定小于它的兄弟节点
   while(x / 2 > 0 && h[x / 2] > h[x])
   {
       heap_swap(x ,x/2);
       x/=2;
   }
}

int main()
{
    int n;
    cin>>n;
    int m = 0;
    while(n--)
    {
        string op;
        cin>>op;
        if(op == "I")
        {
            int x;
            cin>>x;
            sz++;
            m++;
            h[sz] = x;
            hp[sz] = m;//第sz个节点是第m个插入的
            ph[m] = sz;//第m个插入的是sz这个节点
            up(sz);
        }
        else if(op == "PM")
        {
            cout<<h[1]<<endl;
        }
        else if(op == "DM")
        {
            heap_swap(1,sz);
            sz--;
            down(1);
        }
        else if(op == "C")
        {
            int k,x;
            cin>>k>>x;
            k = ph[k];
            h[k] = x; 
            down(k);
            up(k);
        }
        else
        {
            int k;
            cin>>k;
            k = ph[k];//第k个插入的节点
            heap_swap(k,sz);
            sz--;
            down(k);
            up(k);
        }
    }
}

多想想就会了。

9.哈希表(拉链、开放寻址)

拉链法

几个注意点:

  1. 哈希表就是把要存储的值x通过哈希函数映射到数组里面当下标。然后在数组后面挂着数组或者链表去存储。好处是查找效率是接近O(1)的。
  2. 取哈希函数的模数的时候,一般取离范围值最近的质数,可以减少哈希冲突(公式)
  3. 拉链法就是数组后面挂着链表
#include <iostream>
#include <cstring>
using namespace std;

const int N = 100003;//拉链法需要找到一个离范围最近的质数去映射,可以减少哈希冲突

int h[N],e[N],ne[N],idx;

void insert(int x)
{
    int k = (x%N+N)%N;//如果x是负数,x%N也是负数,这是c++的特性,因此要先+N,再%N让它变成正数
    e[idx] = x;
    ne[idx] = h[k];
    h[k] = idx++;
}

bool query(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()
{
    memset(h,-1,sizeof h);
    int n;
    cin>>n;
    while(n--)
    {
        string op;
        cin>>op;
        if(op == "I")
        {
            int x;
            cin>>x;
            insert(x);
        }
        else
        {
            int x;
            cin>>x;
            if(query(x)) cout<<"Yes"<<endl;
            else cout<<"No"<<endl;
        }
    }
}

开放寻址法

#include <iostream>
#include <cstring>
using namespace std;


const int N = 200003,null = 0x3f3f3f3f;//用无穷大代表null
int h[N];

//如果里面有x,就返回x的位置,如果没有x,就返回x应该在的位置
int find(int x)
{
    int k = (x%N+N)%N;
    while(h[k] != null && h[k] != x)//两个条件只要满足一个我就返回了
    {
        k++;
        if(k == N) k = 0;
    }
    return k;
}

int main()
{
    memset(h,0x3f,sizeof h);
    int n;
    cin>>n;
    while(n--)
    {
        string op;
        cin>>op;
        if(op == "I")
        {
            int x;
            cin>>x;
            h[find(x)] = x;
        }
        else
        {
            int x;
            cin>>x;
            if(h[find(x)] == x) cout<<"Yes"<<endl;
            else cout<<"No"<<endl;
        }
    }
}

10.字符串哈希

全称字符串前缀哈希法,把字符串变成一个p进制数字(哈希值),实现不同的字符串映射到不同的数字。
对形如 X1X2X3⋯Xn−1XnX1X2X3⋯Xn−1Xn 的字符串,采用字符的ascii 码乘上 P 的次方来计算哈希值。

映射公式 (X1×Pn−1+X2×Pn−2+⋯+Xn−1×P1+Xn×P0)modQ
注意点:

  1. 任意字符不可以映射成0,否则会出现不同的字符串都映射成0的情况,比如A,AA,AAA皆为0
  2. 冲突问题:通过巧妙设置P (131 或 13331) , Q (264)(264)的值,一般可以理解为不产生冲突。
  3. 不用去自己取模,用ull溢出之后的值就相当于模了Q。这是经验
  4. 两个数组都要是ull,你把公式分配律用一下就可以发现,p也要modQ

区间和公式的理解: ABCDE 与 ABC 的前三个字符值是一样,只差两位,
乘上 P2 把 ABC 变为 ABC00,再用 ABCDE - ABC00 得到 DE 的哈希值。

#include <iostream>

using namespace std;

const int N = 100010;

char str[N];
unsigned long long p[N],h[N];//p数组某种意义来说是减少计算量的
int P = 131;

int n,m;

unsigned long long query(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;
    for(int i = 1;i<=n;i++) h[i] = h[i-1]*P+str[i];//把字符串当成一个数字,*进制加上自己的这个数字就好了
    //h[i]代表的是每一个前缀字符串的哈希值,比如h[2]代表前两个字符组成的前缀字符串的哈希值,因此h[3] = h[2] * p + str[i]
    //你可以和133 = 13 * 10 + 3(base = 10)进行类比,哈希值就相当于一个P进制的数字。
    //判断区间的子串是否相等,就可以看它们哈希值之间的差值是否相等,相等子串就相等。
    
    while(m--)
    {
        int l1,r1,l2,r2;
        cin>>l1>>r1>>l2>>r2;
        
        if(query(l1,r1)==query(l2,r2)) cout<<"Yes"<<endl;
        else cout<<"No"<<endl;
    }
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值