AcWing算法基础课 第二讲数据结构小结(持续更新中)

目录

一、链表

介绍

单链表

介绍

初始化操作

链表头插入操作

删除操作

任意位置插入操作

应用

双链表

介绍

 初始化操作

 插入操作

删除操作

应用

二、栈

含义

插入操作

删除操作 

应用

单调栈

含义

应用

三、队列

队列

含义

 应用

单调队列

含义

应用

四、KMP算法

提出问题

含义

实现逻辑

next数组

匹配过程

应用

五、Trie

含义

常用操作

插入操作

查询操作

应用1:Trie字符串统计 

应用2:最大异或对

思路

题解

六、并查集

含义

基本原理

疑惑

 优化:路径压缩

应用1:合并集合 

应用2:连通块中点的数量

思路

代码

应用3:食物链

思路

代码

七、堆

含义

存储操作

up操作 

down操作

应用1:堆排序

 思路

操作一:插入操作

操作二:找到最小值

操作三:删除最小值

操作四:删除任意元素

操作五:修改任意元素的值

建堆

输出

完整代码

应用2:模拟堆

思路

代码

八、哈希表


一、链表

介绍

链表是一种物理存储单元非连续非顺序存储结构,数据元素的逻辑顺序是是通过链表中的指针链接次序实现的。

链表由一系列结点(链表中的每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两部分:一个是存储数据的数据域,另一个是存储下一个结点地址的指针域

单链表

介绍

单链表的每个节点中除了信息域以外还有一个指针域,用来指出其后续节点,单向链表的最后一个节点的指针域为空(NULL)。单向链表由头指针唯一确定。因此单链表可以用头指针的名字命名,例如头指针名为head的单链表称为表head,头指针指针单链表的第一个节点。


struct Node
{
    int date;
    struct Node * next
}

new Node()

在每次创建一个新的链表的时候,都需要调用new Node()函数,但是这个操作非常慢;在笔试题里面数据比较大,不推荐使用,肯定会超时

这样用数组模拟链表的操作好处是:效率快,因为链表的new函数比较慢,是动态链表;而数组模拟链表是静态链表

在链表中,有数据域和结构域;用数组模拟需要两个数组,一个是e[N](存储值),一个是ne[N],(存储某个点的next指针是多少),这两个数字是用下标关联起来的。

这里面空结点下标用-1来表示,idx存储当前用到了哪一个结点(相当于指针)

初始化操作

在进行任何操作之前,应该先初始化一下。

初始化时链表是空的,head应该指向-1,idx等于0


void init()
{
    head = -1;
    idx = 0;
}

链表头插入操作

其实用数组模拟链表是比较简单的。把元素插入链表首位,共有两步

第一步是把插入元素的指针指向原来的首元素。

第二步把head指针指向插入元素


void add_to_head(int x)
{
    e[idx] = x;//将插入元素存储起来
    ne[idx] = head;//把插入元素指向head指向的元素
    head = idx;//把head指向插入元素
    idx++;//更新idx,
}

删除操作

将指定位置后面的结点删掉,只需要一步,连续把ne数组往后移两次

指向把指定位置的节点直接跳过后面的第一个元素,指向后面的第二个元素


void remove(int k)
{
    ne[k] = ne[ne[k]];
}

任意位置插入操作

给定一个插入位置,把指定元素插入位置后面;

这时候操作还是分为两步

第一步:把要插入元素指向插入位置后的元素

第二步,把插入位置的元素指向要插入元素

void add(int k,int x)
{
    e[idx] = x;//将插入元素存储起来
    ne[idx] = ne[k];//将插入元素指向插入点后面的元素
    ne[k] = idx;//将插入位置的节点指向插入元素
    idx++;//
}

应用

传送门:826. 单链表 - AcWing题库

-------------------------------------------------------代码分割线-----------------------------------------------------------

#include <iostream>

using namespace std;

const int N = 1e5 + 10;
int e[N],ne[N],head,idx;
//head为头节点,idx表示用到了哪一个节点
//数组e相当于信息域,存储值;数组ne相当于指针域,指向下一个节点

void init()//初始化操作
{
    head = -1;
    idx = 0;
}

//链表头插入操作
void add_to_head(int x)
{
    e[idx] = x;//值存下来
    ne[idx] = head;//这个节点的指针指向头节点
    head = idx;//头节点指向这个节点
    idx++;//然后更新用到的节点位置
//熟练后可以把两行写成一行 head = idx++,害怕你们不理解,所以分开写了
}

//删除操作
void remove(int k)
{
    ne[k] = ne[ne[k]];//就是指针跳过需要删除的点
}

//指定位置插入
void add(int k,int x)
{
    e[idx] = x;
    ne[idx] = ne[k];//插入位置的指针指向插入点后面的节点
    ne[k] = idx;//插入点后面的节点的指针指向插入元素的节点
    idx++;//更新用到的节点
}

int main()
{
    int n;
    cin >> n;
    
    init();//不要忘了初始化
    
    while(n--)
    {
        char op;
        int x,k;
        cin >> op;
        
        if(op == 'H')
        {
            cin >> x;
            add_to_head(x);
        }
        else if(op == 'D')
        {
            cin >> k;
            if(!k)
                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] << ' ';
    cout << endl;
    return 0;
}

双链表

介绍

首先,为什么要用数组模拟双链表呢?

为了优化某些问题

对比一下单链表,看看用数组模拟有什么区别

单链表是有一个指针,指向后;双链表有两个指针,一个指向上一个节点,一个指向下一个节点;

所以用数组模拟双链表需要三个数组,作用分别为双链表的数据域(存储数据)、双链表的指针域1(往上一个节点指的指针)、双链表的指针域2(往下一个节点指的指针)

 初始化操作

用0号点表示左端点,1号点表示右端点。此时0号点的右指针应该指向1号点,1号点的左指针应该指向0号点。如下图

void init()
{
    r[0] = 1,l[1] = 0;
    idx = 2;
}

 插入操作

把元素插入到k后面

1、首先,先把新插入元素的左右指针建好

初步代码

void add(int k,int x)
{
    e[idx] = x;
    r[idx] = r[k];
    l[idx] = k;
}

2、然后把原先的两个指针调整一下,都指向插入元素

这一步代码

l[r[k]] = idx;
r[k] = idx

 这两个步骤不能写反,先修改左指针,再修改右指针;不然结果就错误了


综上所述,具体代码为


void add(int k,int x)
{
    e[idx] = x;//存储节点
    //建立 插入节点的左右指针
    r[idx] = r[k];
    l[idx] = k;
    //建立 指向插入节点的左右指针
    l[r[k]] = idx;
    r[k] = idx;
    idx++;//更新idx
}

删除操作

和单链表删除差不多,只不过是两个指针而已

指针直接跨越要删除元素

void remove(int k)//k为三个节点之间的中间点
{
    r[l[k]] = r[k];//指向k的右指针 修改为 指向k的下一位
    l[r[k]] = l[k];//指向k的左指针 修改为 指向k的上一位
    //这样完美的跨过了k这个节点
}

应用

传送门:827. 双链表 - AcWing题库

 这么多操作无非就两种操作:插入操作+删除操作

比较难以处理的就是插入操作,咱们上面讲的插入操作是在右边插入。

如果想在k的左边插入一个元素,不要重新写,只需把k的左边的下标传进去,add(l[k],x); 

一定不能用k - 1,因为节点不一定为连续的,要写成 l[k]。根据链表的性质找到 k 左边的数。


#include <iostream>

using namespace std;

const int N = 1e5 + 10;
//数组e为信息域
//l,r为指针域,分别为左右指针
//idx为当前用到的节点
int e[N],l[N],r[N],idx;

//初始化操作
void init()
{
    r[0] = 1;//左端点的右边时右端点
    l[1] = 0;//右端点的左边是左端点
    idx = 2;//插入元素的节点从2开始,所以下标应该加1
}

void add(int k,int x)
{
    e[idx] = x;//存储值
    //建好插入节点的左右指针
    l[idx] = k;
    r[idx] = r[k];

    //修改原先两个节点的指针
    l[r[k]] = idx;
    r[k] = idx++;
}

void remove(int k)
{
    //跳过当前节点
    r[l[k]] = r[k];
    l[r[k]] = l[k];
}

int main()
{
    int n;
    cin >> n;
    
    init();//记得要初始化
    
    while(n--)
    {
        string op;
        int k,x;
        cin >> op;
        
        if(op == "L")
        {
            cin >> x;
            add(0,x);
        }
        else if(op == "R")
        {
            cin >> x;
            add(l[1],x);//插入点为右端点的左边
        }
        else if(op == "D")
        {
            cin >> k;
            remove(k + 1);
        }
        else if(op == "IL")
        {
            //插入点为下一个节点的左边
            cin >> k >> x;
            add(l[k + 1],x);
        }
        else
        {
            cin >> k >> x;
            add(k + 1,x);
        }
    }
    
    //从左端点的右边开始,到右端点结束,更新方式为右指针
    for(int i = r[0];i != 1;i = r[i])
        cout << e[i] << ' ';
    cout << endl;
    return 0;
}

二、栈

含义

栈:它是一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,把另一端称为栈底。

简单来说,栈就是先进后出!!!

插入操作

向一个栈插入新元素又称为进栈、入栈或压栈。它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素

stk[++tt] =  x;

删除操作 

从一个栈删除元素又称为出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为栈顶元素

tt--;

应用

传送门:828. 模拟栈 - AcWing题库

代码 


#include <iostream>

using namespace std;

const int N = 1e5 + 10;
int stk[N],tt;//stk数组模拟栈,tt表示下标

int main()
{
    int m;
    cin >> m;
    while(m--)
    {
        string op;
        cin >> op;
        
        if(op == "push")
        {
            int x;
            cin >> x;
            stk[tt++] = x;//存储值
        }
        else if(op == "pop")
            tt--;//删除栈顶元素
        else if(op == "empty")
            cout << (tt >= 0 ? "NO" : "YES") << endl;//看下标是否为0
        else
            cout << stk[tt - 1] << endl;//输出栈顶元素
    }
    return 0;
}

单调栈

含义

单调栈,是一种数据结构,它里面的数据具有单调性,就是栈中元素按照递增或者递减顺序排列。每个元素只会进栈一次,进栈时会把破坏单调性的栈内元素弹出

最大好处就是时间复杂度是线性的,每个元素只遍历一次。

而单调栈的应用场景多半为序列元素左边最近的最小值等等类型的。

解法也比较简单。第一步,想暴力怎么做。第二步,从中发现一些单调性,然后优化(和双指针有点像哦)。

下面就直接举例子讲解吧,要不然太抽象了!

应用

传送门:830. 单调栈 - AcWing题库

 思路

第一步:想暴力做法

暴力做法也是超级好想的,就是使用双层for循环遍历数组元素,比较两个元素的大小

大致代码(不是很详细,只是为了方便下一步)

for(int i = 0;i < n;i++)
{
    for(int j = i - 1;j >= 0;j--)
    {
        if(a[i] > a[j])
        {
            cout << a[j] << endl;
            break;
        }
    }
}

第二步:发现一些性质

其实,可以优化的是,当外层的 i 走的时候,可以用一个栈存储此时 i 左边的元素。

注意:栈刚开始是的,当外层循环的 i 每往右走一次的时候,就往栈里面加入一个全新的数字。栈里面存的元素是从第一个到 i - 1的。每一次查询的时候是从栈顶开始找,找到第一个比 i 小的元素停下,没有返回-1。

而此时我们可以发现一些规律。

如果a3 大于 a5,且a3在a5的左边,那么答案一定不可能是a3。因为答案是离指针最近的且小于指针的数。So,a3是无用的数据

从这里我们可以看出,栈里面存了一下没有用的数据,当我们删除后,就找到了单调性。而这个单调性看我们想要哪种。

上图吧!!!

这时我们就发现了一些 根本不会用到的元素,就是"垃圾数据",然后我们要删除这些,之后栈内元素就有了单调性

但是哪些元素是需要删除的呢?就是那些呈逆序对的点,统统没有用。然后看题意,要求什么左边最近的最小值,就把栈内逆序对中大的值删掉

把无用数据删除后,栈里面就是单调上升的了。

然后用栈顶元素和 ai 进行比较。如果栈顶元素大,就删除栈顶,直到新的栈顶元素小于 ai 为止,然后把 ai 插入栈里面,从而这个栈内元素就又是单调递增的的了

代码

#include <iostream>

using namespace std;

const int N = 1e5 + 10;
int stk[N],tt;//数组模拟栈,tt为下标

int main()
{
    int n;
    cin >> n;
    for(int i = 0;i < n;i++)
    {
        int x;
        cin >> x;
        //当栈不为空且栈顶元素大于此时的值时,删除栈顶,直至小于为止
        while(tt && stk[tt] >= x)
            tt--;
        if(!tt)//如果tt为空,就说明没有值比它小,就输出-1
            printf("-1 ");
        else//有的话就输出栈顶,离它最近的最小的元素
            printf("%d ",stk[tt]);
        stk[++tt] = x;//记得要把它存入栈
    }
    return 0;
}

三、队列

队列

含义

队列是一种特殊的线性表,特殊之处在于它只允许在表的前端进行删除操作,而在表的后端进行插入操作,和栈一样,队列是一种操作受限的线性表。进行插入操作的端叫做队尾;进行删除操作的叫做队头

一句话:先进先出

举个形象的例子,就是  食堂打饭先排队的先打完饭先走,后排队的后走

常用操作

插入操作:q[ ++ tt ] = x;

删除操作:tt--;

 应用

传送门:829. 模拟队列 - AcWing题库

 代码

#include <iostream>

using namespace std;

//数组q模拟队列,hh为队头,tt为队尾
const int N = 1e5 + 10;
int q[N],hh,tt = -1;

int main()
{
    int n;
    cin >> n;
    
    while(n--)
    {
        string op;
        cin >> op;
        if(op == "push")
        {
            int x;
            cin >> x;
            q[++tt] = x;//入队
        }
        else if(op == "pop")
            hh++;//出队
        //如果hh下标 <= tt下标,说明队列里面有元素,当队列里又1个元素时,hh == tt
        else if(op == "empty")
            cout << (hh <= tt ? "NO" : "YES") << endl;
        else//输出队头元素
            cout << q[hh] << endl;
    }
    return 0;
}

单调队列

含义

如果一个选手比你小还比你强,你就可以退役了。”——单调队列的原理

单调队列是一种主要用于解决滑动窗口类问题的数据结构,不断地向缓存数组里读入元素,也不时地去掉最老的元素,不定期的询问当前缓存数组里的最小的元素。

滑动窗口:在长度为 n 的序列中,求每个长度为 m 的区间的区间最值。它的时间复杂度是 O(n) ,在这个问题中比 O(nlog⁡n) 的ST表和线段树要优。

基本思想:维护一个双向队列,遍历序列,仅当一个元素可能成为某个区间最值时才保留它。

应用

传送门:154. 滑动窗口 - AcWing题库


首先,我们先来想想暴力做法

暴力做法比较简单,就是在这个固定的窗口看成一个队列,窗口的滑动可以看成队头的删除操作和队尾的插入操作,然后遍历队列找到最小值。

以题目上的例子模拟一下。


找最小值

最小值为1,然后在队尾插入元素,再找最小值

最小值还是1,进行在队尾插入元素,找最小值

最小值为-1,这时候需要删除队头,在队尾进行插入操作,找最小值,后面重复此操作


看完暴力做法后,我们应该想想队列里面的某些元素是否没有用到,是否可以删除这些元素。从而进行优化。

接下来,我们详细分析一下哪些元素是没有用到的。

就比如这个窗口,3、-1、-3。当-3进来这个窗口之后,最左边的3肯定不会用到。为什么呢?

因为我们要找的是窗口最小值。只要-3在的一天,3就不可能是窗口的最小值,这时候3就没有用处,就可以删除了。同样的,-1也是一样。

现在,应该可以看出来规律了吧。就是当队列里面前面的数比后面大的数时(逆序),就应该删除

这时候逆序的数是没有用的

删除后,队列就成为了一个单调递增的队列。

这时候,队列的最小值就为队头了。

大致步骤为

1、  想想用普通队列怎么做

2、  删除队列里面没有用的元素->队列有了单调性

3、  可以用O(1)的时间复杂度从队头/队尾取出最值

//找最小值
int hh = 0,tt = -1;
for(int i = 0;i < n;i++)
{
    // 判断队列是否为空,看看队头的下标是否小于滑动窗口最左边的下标
    if(hh <= tt && q[hh] < i - k + 1)
        hh++;
     // 判断队尾的元素是否大于等于即将入队的元素
     // 如果大于等于的话,就说明队尾元素没有用,可以删除
     while(hh <= tt && a[q[tt]] >= a[i])
         tt--;
     q[++tt] = i;// 把下标存入队列
     // 判断此时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 && q[hh] < i - k + 1)
        hh++;
    // 判断队尾元素是否小于等于即将入队的元素
    // 如果小于等于的话,就说明是无用元素,可以删除
    while(hh <= tt && a[q[tt]] <= a[i])
        tt--;
    q[++tt] = i;//  把下标存入队列
    // 判断此时i是否满足滑动窗口的元素个数,当大于等于个数时,才能输出
    if(i >= k - 1)
        printf("%d ",a[q[hh]]);
}
puts("");// 换行


完整代码

#include <iostream>

using namespace std;
const int N = 1e6 + 10;
int a[N],q[N];

int main()
{
    int n,k;
    scanf("%d%d",&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 && q[hh] < i - k + 1)
            hh++;
        // 判断队尾的元素是否大于等于即将入队的元素
        // 如果大于等于的话,就说明队尾元素没有用,可以删除
        while(hh <= tt && a[q[tt]] >= a[i])
            tt--;
        q[++tt] = i;// 把下标存入队列
        // 判断此时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 && q[hh] < i - k + 1)
            hh++;
        // 判断队尾元素是否小于等于即将入队的元素
        // 如果小于等于的话,就说明是无用元素,可以删除
        while(hh <= tt && a[q[tt]] <= a[i])
            tt--;
        q[++tt] = i;
        if(i >= k - 1)
            printf("%d ",a[q[hh]]);
    }
    puts("");
}

四、KMP算法

提出问题

如果让你匹配一个模式串(aabaaf)在字符串(aabaabaaf)中出现下标,你会怎么做呢?

可能你首先想到的是暴力做法,使用两层for循环,比较字符串和模式串是否相等。

如果不相等,字符串就跳到第二位(第二个a)开始比较,模式串会跳到首位重新匹配

这样就浪费之前的信息,导致指针回溯,增加了时间复杂度,为O(mn)


此时,重头戏来了,它是KMP算法,使用KMP算法,当匹配不成功时,模式串会自动跳过前几项匹配可以成功的模式串,从没有开始匹配的位置与字符串开始比较。就是利用了之前匹配成功的信息,减少了指针回溯

 那么为什么呢?KMP算法的逻辑是什么呢?

含义

首先,我们来看看百度百科上的定义

KMP算法是一种改进的字符串匹配算法。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next数组实现,数组本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)

用一句我的话来理解,就是

"一个成功人士,成功的不是他能够走多远,而是他能很快记起失败的路并继续走下去"——StarHui

是不是感觉好记了一些,貌似我也是有一点才华的

实现逻辑

为什么模式串的指针会自动跳到某个位置,而不是首位?其实原理很简单,就是利用了之前匹配成功的信息,然后减少匹配次数直接跳到一定位置

在讲解之前,我们需要先了解几个定义。

真前缀:字符串左部的任意子串,不能包含本身,长度必须小于字符串本身。

例如 abc的前缀为a,ab

真后缀:字符串的右部的任意子串,不能包含本身,长度必须小于字符串本身。

例如 abc的后缀为bc,c

next数组

首先,我们需要求模式串的每一个子串的相等前后缀的长度,然后用数组存下来,方便后面使用。

为什么要求相等前后缀的长度呢?你们知道吗?

是为了减少指针的回溯,减少匹配次数,利用之前匹配成功的信息,

为什么找相同前后缀就是利用匹配成功信息呢?

当最后一个不匹配的话,f之前的都匹配成功了。所以可以把字符串匹配成功的元素看成模式串。而想要减少匹配次数的话,只需要寻找最长的相同字符串的后缀与模式串的前缀长度了。又因为字符串和模式串相同,所以就变成了寻找模式串最长的公共前后缀长度。

通俗来说,就是换了一个参考物,因为匹配成功的字符串和模式串相等,索性在模式串上求一下,不用管字符串了,毕竟它们是一样的。

正是这样,KMP算法才会自动跳到一个位置,而不是从头匹配

知道了next数组的妙用,接下来看看怎么求next数组吧!!!

主要思路就是使用两个指针进行匹配,i指针扫描的是后缀末尾j指针扫描的是前缀末尾。如果匹配不成功,就回退;匹配成功的话,就继续往右走,继续匹配


//i是用来扫描后缀的,j是用来扫描前缀
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++;//匹配j就加1
    ne[i] = j;//存下来
}

大家可能不理解为什么要让p[i] 和p[j + 1] 进行匹配?

这里统一解释一下。只是为了预判,如果不匹配时,直接查上一位的next数组,然后利用匹配成功的信息,减少指针回溯,j指针会自动跳到一个位置,然后继续进行匹配。

匹配过程

匹配过程就比较简单了,还是让s[i] 和p[j + 1]进行匹配,为什么呢?

就是为了防止不匹配时,查表方便,比较咱们存next数组时,此位不匹配时应该跳到上一位next数组里面存的值,减少指针的回溯。

举一个例子来帮助理解

此时 f 不匹配,应该查上一位next数组里面的值,然后跳到next[ j ] 那里。


for(int i = 1,j = 0;i <= m;i++)
{
// 当j为0或者不匹配时,就往前跳,而跳的位置需要查上一位的next数组
    while(j && s[i] != p[j + 1])
        j = ne[j];
//跳出while循环有两种,如果它们匹配的话,j就加1,再继续匹配
    if(s[i] == p[j + 1])
        j++;
    if(j == n)
    {
        printf("%d ",i - n);//说明匹配成功了,输出下标
        j = ne[j];
//注意一定要执行这一步,因为不知道字符串后面还有没有可以匹配的,否则就会不动了
    }
}

应用

传送门:831. KMP字符串 - AcWing题库


#include <iostream>

using namespace std;

const int N = 1e5 + 10,M = 1e6 + 10;
char p[N],s[M];
int ne[N];//为了防止一下头文件有next函数,就把next数组写成ne数组

int main()
{
    int n,m;
    cin >> n >> p + 1 >> m >> s  + 1;//下标从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;
    }
    
    //匹配过程
    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;
}

注意:打印后要让 j = ne[ j ],为了防止后面还有可以匹配成功的元素,然后查next数组看看最少可以移动几位再进行匹配

五、Trie

含义

最简单的定义:高效地存储和查找字符串集合的数据结构。

字典树,又称单词查找树,Trie树,是一种树状结构,是哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不限于字符串)。优点是利用字符串的公共前缀来减少查询时间,最大限制地减少无谓的字符串比较,查询效率比哈希树高

常用操作

插入操作

当我们存储字符串的时候,我们首先应该看一下根节点有没有字符串首元素没有的话就创建出来,以此类推,看一下第一个节点有没有下一个字母的节点没有的话就创建出来。如果前面有节点的话,就一直走到字符串没有节点的位置,然后创建新的节点

用一句形象的话来表示

"有路走路,没有路就建路,然后走过去"

void insert(char str[])
{
    int p = 0;//p就是一个指针,刚开始指向root
    //由于字符串结尾是"\0",可以通过str[i]来判断是否到字符串末尾
    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]++;//以这个字符结束的单词次数加1,即给单词做标记
}

查询操作

查询操作就是查询字典树是否含有某个字符串

就是在字典树里面查找字符串的每一个元素,如果找到了就继续往下找,到末尾记得有没有标记,如果元素全部都有最后一个元素那里有标记,就说明字典树里面这个字符串,反之没有。

每一次查询的都是一个节点,字符串的每一个元素就是一个要查询的节点

比如我们要查找aced这个单词,首先找根节点有没有a节点,再查询a节点有没有c节点,c节点有没有d节点,d节点那里有没有标记

int query(char str[])
{
    int p = 0;//同样的,刚开始指向root
    for(int i = 0;str[i];i++)// 判断是否到字符串末尾
    {
        int u = str[i] - 'a';// 得到要查询的节点
        //遍历树,如果没有此节点说明单词不存在
        if(!son[p][u])
            return 0;
        p = son[p][u];//有这个节点的话,就令p指向这个节点,然后看看这个节点下面是否有下一个元素的节点
    }
    return cnt[p];//返回单词出现次数
}

应用1:Trie字符串统计 

传送门:835. Trie字符串统计 - AcWing题库

代码

/注意本题的英语字母用0~25表示
#include <iostream>

using namespace std;

const int N = 1e5 + 10;
int son[N][26],cnt[N],idx;
char str[N];

void insert(char str[])
{
    int p = 0;//p就是一个指针,刚开始指向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];//指针子节点
    }
    cnt[p]++;//以这个字符结束的单词次数加1,即给单词做标记
}

int query(char str[])
{
    int p = 0;//同样的,刚开始指向root
    for(int i = 0;str[i];i++)
    {
        int u = str[i] - 'a';
        //遍历树,如果没有此节点说明单词不存在
        if(!son[p][u])
            return 0;
        p = son[p][u];//另p指向子节点
    }
    return cnt[p];//返回单词出现次数
}
int main()
{
    int n;
    cin >> n;
    while(n--)
    {
        char op[2];//定义为2的数组是为了防止元素溢出
        cin >> op >> str;
        if(*op == 'I')//不要忘记数组名是首元素地址,解引用后就是首元素
            insert(str);
        else
            printf("%d\n",query(str));
    }
    return 0;
}

应用2:最大异或对

传送门:143. 最大异或对 - AcWing题库

思路

首先,我们来了解一下异或

异或:在二进制表示中,如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0。

其次,就是想想暴力做法怎么做,然后可以用什么数据结构去优化

暴力的思想是先枚举ai,然后再枚举aj,然后ai^aj这样的话,每一个ai都有一个最大值,然后再进行比较大小,最后最大的是结果

暴力解法代码

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int q[N];

int main()
{
    int n;
    cin >> n;
    for(int i = 0;i < n;i++)
        cin >> q[i];
    int res = 0;
    for(int i = 0;i < n;i++)
    {
        for(int j = 0;j < n;j++)
            res = max(res,q[i]^q[j]);
    }
    cout << res << endl;
    return 0;
} 

首先,我们来优化枚举aj的过程,此时把ai看成固定的

举个例子吧,我们把此时的ai看成是11001100......0101

如果想要让异或结果最大,首先我们应该选择最高位是0的,毕竟两位不同时结果是1,、相同结果是0。这时候我们就筛去一部分数,以此类推,一直找ai的二进制相反数

需要注意的是,当分支存在相反的树时,把相同的枝子剪去;要是没有的话,就只能选相同的枝子了。

从而这个过程就可以看成剪枝的过程,每一次都会有数被筛去

这个数据结构看出来了吗?顺时针旋转90°就是Trie树啊


优化代码思路

首先我们把所有ai、aj创建一个Trie数组,根据每一位的0和1创建

然后对于每一个固定ai的话,我们从Trie树从根往下走尽量走与ai当前分枝相反分枝上走(比如ai这一位为0,那么应该往1这一分枝走)。如果存在相反分枝走过去,不存在就没有办法,只能走相同的分枝了,按照这种思想走,走到最后就可以找到异或结果最大的aj了。


上实例

第一步:构建Trie树

void insert(int x)
{
    int p = 0;
    for(int i = 30;i >= 0;i--)
    {
        //x >> i & 1是为了从高位到低位得到每一位是0还是1
        int &s = son[p][x >> i & 1];//&是引用符号,即改变s的值就会改变son[p][x >> i & 1]
        if(!s)//说明不存在该节点
            s = ++idx;//,创建新节点,并给它编号就是为了少写代码
        p = s;//指向子节点
    }
}
/*
void insert(int x)
{
    int p = 0;
    for(int i = 30;i >= 0;i--)
    {
        int u = x >> i & 1;
        //x >> i & 1是为了从高位到低位得到每一位是0还是1
        if(!son[p)[u]
            son[p][u] = ++idx;
        p = son[p][u]
    }
}
*/

难理解代码:int &s = son[p][x >> i & 1]

加引用是因为s是son数组的分枝,如果s改变,son也要改变;这样写是为了少写代码,如果不习惯,可以换第二种写法!!!

第二步:固定ai,找是否存与ai每一位数都相反的数

ai等于2(二进制:010),所以尽量找每一位与它相反的,如果有的话就继续找相反的,找到最后就是一个数,没有的话,就先从相同的枝子走下去,继续找是否有相反的。

int search(int x)
{
    //p是指针,res存储异或后的结果
    int p = 0,res = 0;
    for(int i = 30;i >= 0;i--)
    {
        int s = x >> i & 1;//看看这一位是1还是0
        if(son[p][!s])//如果存在相反的枝子,说明异或后的结果取1
        {
            res += 1 << i;//结果为1后左移i位,就是正确的二进制表示
            p = son[p][!s];//指向与当前位数相反的子节点
        }
        else//没有相反的子节点,只能走相同的
            p = son[p][s];
    }
    return res;
}

难理解代码:res += 1 << i

就是一个把二进制转化为十进制的代码

1 << i结果就是2的i次方,就是进制转换

还是不能理解的话,建议查一下二进制然后转化为十进制

没有相反的数,只能往相同的走,然后再寻找有没有相反的数,以此类推,走到最后,会找到一个数

题解


#include <iostream>
#include <algorithm>

using namespace std;

//每一个数都是以31位二进制数存进去的
const int N = 1e5 + 10,M = 3100010;
int son[M][2],idx;
int a[N];

void insert(int x)
{
    int p = 0;
    for(int i = 30;i >= 0;i--)
    {
        //x >> i & 1是为了从高位到低位得到每一位是0还是1
        int &s = son[p][x >> i & 1];
        if(!s)
            s = ++idx;//,创建新节点。就是为了少写代码
        p = s;//指向子节点
    }
}
/*
void insert(int x)
{
    int p = 0;
    for(int i = 30;i >= 0;i--)
    {
        int u = x >> i & 1;
        //x >> i & 1是为了从高位到低位得到每一位是0还是1
        if(!son[p)[u]
            son[p][u] = ++idx;
        p = son[p][u]
    }
}
*/

int search(int x)
{
    //p是指针,res存储异或后的结果
    int p = 0,res = 0;
    for(int i = 30;i >= 0;i--)
    {
        int s = x >> i & 1;//看看这一位是1还是0
        if(son[p][!s])//如果存在相反的枝子,说明异或后的结果取1
        {
            res += 1 << i;//把二进制转化为十进制
            p = son[p][!s];//指向与当前位数相反的子节点
        }
        else//没有相反的子节点,只能走相同的
            p = son[p][s];
    }
    return res;
}

int main()
{
    int n;
    cin >> n;
    for(int i = 0;i < n;i++)
    {
        cin >> a[i];
        insert(a[i]);
    }
    int res = 0;
    for(int i = 0;i < n;i++)
        res = max(res,search(a[i]));
    cout << res << endl;
    return 0;
}

六、并查集

含义

并查集是一种树型的数据结构,常见操作有两种

1、  将两个集合合并

2、  询问两个元素是否在一个集合当中

并查集进行这两种操作的时间复杂度近乎O(1),而其他数据结构可能完成不了

基本原理

每一个集合用一棵树来表示,树根的编号就是集合的编号。每一个节点存储它的父节点,px]表示x的父节点。

当我们想求某一个点属于哪一个集合的时候

只需要找到它的father,看看是不是树根,如果不是树根的话,就继续往上找,直到找到树根为止,树根编号就是集合编号,这样就知道属于哪一个集合了

疑惑

问题1:如何判断是不是树根呢?

if(p[x] == x)

就是树根,除了根节点之外,p[x]都不等于x


问题2:如何求x的集合编号

while(p[x] != x)
x = p[x];

如果p[x] != x的话,说明此时不是树根,就继续往上找,直到找到树根为止


问题3:怎样合并这两个集合?

假把集合2合并到集合1里面去,x是集合1的编号,y是集合2的编号。就把集合2的父亲设定为集合1就可以了,然后集合2就是集合1的子节点了

p[y] = x

 优化:路径压缩

就是对并查集的一个优化。

当第一个元素第一次找到根节点后,那么这条路径上的所有节点都指向根节点。只会搜一遍,搜完把路上所有的元素都指向根节点。(相当于跨辈分了,儿子、孙子、重孙做兄弟)

在之后的找集合过程中,只需要O(1)的时间复杂度就能找到集合编号

应用1:合并集合 

传送门:836. 合并集合 - AcWing题库

#include <iostream>

using namespace std;

const int N = 1e5 + 10;
int p[N];

int find(int x)//返回x的祖宗节点+路径压缩
{
    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;//刚开始,每个人都是一个集合
    char op[2];
    int a,b;
    while(m--)
    {
        cin >> op;
        cin >> a >> b;
        if(*op == 'M')
        {
            p[find(a)] = find(b);//合并集合,把集合a的父亲赋值为b的祖宗
        }
        else
        {
            if(find(a) == find(b))// 判断祖宗是否相等
                puts("Yes");
            else
                puts("No");
        }
    }
    
    return 0;
}

应用2:连通块中点的数量

传送门:837. 连通块中点的数量 - AcWing题库

思路

这一题和上面的那一道题差不多,就是多了一个操作,题目让输入连通块中点的数量,就是让输出集合的数量。

前两种操作就不说了,要是还不会的,就请看一下之前的并查集的文章,仔细复习一下哦。接下来说一下第三种操作 也就是集合的数量怎么求。

其实也非常的简单,就是开一个数组cnt存储集合的个数。但是注意cnt数组中只有树根存的值才有意义,其他节点存的无意义,只需要维护树根的值即可

那我们怎么维护树根的值呢?一旦进行合并操作时,两个集合就合并成一个集合了,所以说这时候应该改变树根的值。cnt[集合1] += cnt[集合2]

注意:当在进行合并集合操作、更新集合个数时,应该注意能合并的集合是两个集合,而不能是在一个集合里面。也就是需要进行特判,判断这两个集合是否为同一个集合,不是同一个集合时,才能进行合并操作、并更新集合个数

代码

#include <iostream>

using namespace std;

const int N = 1e5 + 10;
int p[N],cnt[N];

int find(int x)//找到一个结点的 "祖宗",+ 路径压缩
{
    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;//每一个元素都是一个集合
        cnt[i] = 1;//数量都为1
    }
    string op;
    int a,b;
    while(m--)
    {
        cin >> op;
        if(op == "C")
        {
            cin >> a >> b;
            a = find(a),b = find(b);//分别找到a、b的祖宗
            if(a != b)//需要进行特判,防止a和b已经在一个集合里面
            {
                p[a] = b;//合并集合,把集合a合并到集合b中
                cnt[b] += cnt[a];//合并后集合b的数量应该加上集合a的数量
            }
        }
        else if(op == "Q1")
        {
            cin >> a >> b;
            if(find(a) == find(b))
                puts("Yes");
            else
                puts("No");
        }
        else
        {
            cin >> a;
            cout << cnt[find(a)] << endl;
        }
    }
}

应用3:食物链

传送门:240. 食物链 - AcWing题库

思路

首先,我们应该读懂题目。

题目说了是环形关系。当我们知道其中两个种类A、B分别与另一个种类关系C后,那么这两个种类A、B的关系了。

由于题目要求是环形的,这时候我们就知道B是被A吃的

当给我们两个种类之间的关系后,我们要把这些种类用树存起来。但是我们怎么用树来表示它们之间的关系呢?

精华之处:我们需要录每一个节点和根节点之间的关系,知道了这个关系后,我们就能确定任意两个节点之间的关系。

那么如何表示每一个节点和根节点的关系呢?

由于是三种种类循环被吃的过程,我们用节点到根节点的距离来表示和根节点之间的关系

如果一个节点到根节点的距离是1,表示这个节点可以吃根节点;如果一个节点到根节点的距离是2的话,表示这个节点被根节点吃;如果距离是3的话,说明它和根节点是同类

由于所有的种类都是三个一个环形结构,所以我们可以%3进行判断。

余1:表示可以吃根节点

余2:表示被根节点吃

余0:表示和根节点是同类

距离解释:就是第几代,如果是第一代,到根节点的距离就是1,以此类推第n代,到根节点的距离是n。

维护并查集

本来只能维护当前节点到父节点之间的距离,但是当一个节点找到根节点后,进行路径压缩的时候,把每一个节点存的距离更新为到根节点之间的距离

int find(int x)
{
    if(p[x] != x)
    {
        int u = find(p[x])// 先把p[x]的祖宗存一下,因为还需要用到p[x]
        d[x] += d[p[x]];//经过递归后,d[p[x]]存的是父节点到根节点的距离,它们相加就表示x到根节点的距离
        p[x] = u;//认祖归宗
    }
    return p[x];
}

[x] += d[p[x]]什么意思呢?d[x]存的是x到父节点的距离。本来p[x]的父节点不是根节点的,但是经过路径压缩后,p[x]直接指向根节点,那么d[p[x]]就表示p[x] 到根节点的距离,两者相加,就是x到根节点的距离。

注意事项

在写代码的时候,需要考虑一些问题,就是这两个不在一个树上怎么办?

我们应该把它们合并到一个树上,比如把树根1的父节点设置树根2。那么距离怎么树根1到树根2的距离怎么办呢?其实这个距离需要我们设置,根据条件设置。如果说x和y是同类,所以它们两个到根节点的距离%3后应该一样,根据这个条件继续推下去,就能得到距离了。

接下来,还有一个吃与被吃的种类不在同一棵树上的情况。同理,我们可以由关系式推出d[p[x]]等于什么

代码

#include <iostream>

using namespace std;

const int N = 5e5 + 10;
int p[N],d[N];//数组p存父节点,数组d存到父节点的距离(代的概念)

int find(int x)
{
    if(p[x] != x)//如果父节点不是根节点的话
    {
        int u = find(p[x]);//先把p[x]的根节点存下来
        //经过递归后,d[p[x]存的是x的父节点到根节点的距离,所以d[x]经过加上d[p[x]后,表示x到根节点的距离
        d[x] += d[p[x]];
        p[x] = u;//父节点的父节点变成根节点
    }
    return p[x];//返回x父节点
}

int main()
{
    int n,k;
    cin >> n >> k;
    for(int i = 1;i <= n;i++)
        p[i] = i;
    int op,x,y,res = 0;
    while(k--)
    {
        cin >> op >> x >> y;
        if(x > n || y > n)//当给的编号大于题目的编号,就说明是假话
            res++;
        else
        {
            int px = find(x),py = find(y);//px、py分别表示x、y的根节点
            if(op == 1)
            {
                //我们需要考虑这两个种类在不在一棵树上
                if(px == py && (d[x] - d[y]) % 3 != 0)//如果在一棵树上,那么不是一个种类用%3的余数不同表示
                    res++;
                else if(px != py)
                {
                    p[px] = py;//合并树,把px合并到py上去
                    d[px] = d[y] - d[x];
//d[x] + d[px]表示x到跟节点的距离,d[y]表示y到根节点的距离;由x和y是同类可得,(d[x] + d[px] - d[y]) % 3 == 0
                }
            }
            else
            {
                //在一棵树上,x吃y用距离表示,(x到根节点的距离- y到根节点的距离) % 3 - 1 == 0
                if(px == py && (d[x] - d[y] - 1) % 3 != 0)
                    res++;
                else if(px != py)
                {
                    p[px] = py;//合并树
                    d[px] = d[y] + 1 - d[x];//距离根据关系式可以推出
                }
            }
        }
    }
    printf("%d\n",res);
}

七、堆

含义

堆(Heap)是计算机科学中一类特殊的数据结构,是最高效优先级队列。堆通常是一个可以被看做一棵完全二叉树的数组对象。

完全二叉树:树长得非常平衡,除了最后一层节点之外,其他的节点都会有两个子节点,并不会出现空节点。最后一层节点从左到右排列

堆的话满足一个性质,以小跟堆为例,每一个节点都是小于等于左右子节点的(即两个儿子),因此根节点是堆内的最小值

而手写堆的的常用操作有几个

1.  插入一个数

2.  求集合当中的最小值

3.  删除最小值

4.  删除任意一个元素

5.  修改任意一个元素

STL的堆只能直接实现前三种操作,后面的两种操作只能间接实现。而手写一个堆可以全部实现

存储操作

和之前讲的那些数据结构不一样,堆是用一个一维数组存储的。

根节点存到下标为1的位置,节点的左儿子存到下标为2x的位置;右儿子存到下标为2x+1的位置

接下来,重点来了。前面讲的五种操作其实只需要两个操作组合就行了。别眨眼哦!

up操作 

谨记小根堆性质:父节点比它的左右儿子的值小

如果说我把一个数变小了,不满足性质了,所以应该往上升。up操作看一下一个数往上走到什么位置。

此时2不满足性质,应该把2的父节点和它的兄弟进行比较,谁最小,谁变成新的父节点。

还是不满足,2继续往上走

OK,已经满足性质了,这就是up操作

void up(int x)
{
    //当x有父节点,且父节点大于x,所以它们应该互换
    while(x / 2 && h[x / 2] > h[x])
    {
        swap(h[x / 2],h[x]);
        u /= 2;//更新父节点
    }
}

down操作

假如现在已经给我一个小根堆,我们把根节点的值改变一下,看看是否还满足小于等于它的两个子节点,不满足的话,就往下走。

比如说,把根节点的值修改为6。那我们怎么办呢?只需要把节点和它的两个子节点进行比较,谁最小,谁就是新的节点。

6和3、4比较,6大于它的两个子节点,所以把最小值3变成新的根节点。

交换后,还是不满足性质,于是再把最小值和6互换位置

此时,已经满足性质了,一个新的小根堆就出现了

这就是dowm操作,就是一个值变大了,我们应该往下压。

void down(int x)//参数为下标
{
    int t = x;//t存的是最小值的下标,默认父节点是最小值,后面会更新
    //如果x有左儿子,并且左儿子小于h[x],说明已经不符合小根堆的性质,此时最小值的下标应该是左儿子的下标
    if(x * 2 <= cnt && h[x * 2] < h[t])
        t = x * 2;
     //如果x有右儿子,并且右儿子小于最小值(h[x] h和x的左儿子中的最小值),此时最小值的下标应该是右儿子的下标
    if(x * 2  + 1 <= cnt && h[x * 2 + 1] < h[t])
        t = x * 2 + 1;
    if(t != x)//最后发现最小值的下标不是刚开始父节点的下标,就说明父节点需要换了
    {
        swap(h[t],h[x]);// 父节点和比它小的儿子互换
        down(t);// 为了维护小根堆
    }
}

应用1:堆排序

传送门:838. 堆排序 - AcWing题库

 思路

首先,我们来仔细看一下这个题,没错。它就是面试常考的topk问题。一般面试官要求不能全局排序(就是先排序再输出)。其实是超级简单的,只需要一个堆就能完成了。

下面就对如何组合上面的两个操作来完成题目提到的五种操作。

统一一下格式,heap表示堆数组,size表示数组大小

操作一:插入操作

插入操作很简单,就是在数组末尾加入一个新元素,然后使用up操作,看看可以升到哪。

heap[++size] = x;
up(size)

操作二:找到最小值

由于是小根堆,所以一个节点都小于等于它的两个子节点。以此类推,根节点是堆里面的最小值,下标为1。

min = heap[1]

操作三:删除最小值

删除最小值就是删除根节点,就是删除数组首元素。是需要一定的技巧性的。

由于是一个一维数组,首元素删除比较麻烦,此时我们可以把首元素被数组末尾元素覆盖掉,然后size - 1,就完成了首元素的删除。但是我们还需要维护一下堆,毕竟现在一定不满足小根堆的性质了,我们只需要使用dowm操作,维护一下即可

heap[1] = heap[size];
size--;
down(1);

操作四:删除任意元素

其实是和删除根节点类似,但是维护堆的时候需要分情况讨论,因为不知道覆盖后的值和另外两个子节点的值大小关系

但是我们可以不需要判断大小关系,直接先down一遍再up一遍。程序只会执行一个函数。知道为什么吗?

因为只有此时的元素大于它的两个子节点时,才会执行down操作;同样的,只有元素小于它的父节点和它的兄弟时,才会进行up操作。二者选一,妙得很!!!

heap[k] = heap[size];
size--;
down(k);
up(k);

操作五:修改任意元素的值

和上面类似,修改元素的值后,先down一遍再up一遍,轻松维护小根堆

heap[k] = x;
down(k);
up(k);

五种操作已经通过up、down组合完了,但是这些操作是在堆里面进行的。目前我们还没有堆,所以我们需要建堆。

建堆

建堆的方式有好多种,我们可以用上面实现的插入操作,一个一个插入数。但是时间复杂度比较高,为O(nlogn)。这时候我们可以改变建堆方式,时间复杂度为O(n)。就是从n/2开始down到0

为什么要从n/2~0执行down操作呢?是因为n/2~0是有左右儿子的,有左右儿子才能向下移动。建议画个图,就能理解了。

for(int i  = 1;i <= n;i++)
    cin >> h[i];
for(int i = n / 2;i >= 1;i--)
    down(i);

输出

输出前k小的数,可以每次输出堆顶,输出后把堆顶删除,然后再进行排序,此时排序后的堆顶又是最小的元素,以此类推,可以输出前k小的数.

就是组合了操作3

while(k--)
{
    printf("%d ",h[1]);//输出堆顶
    h[1] = h[cnt];//把栈顶用末尾元素覆盖
    cnt--;//然后把数组大小减1,就完成对堆顶元素的删除
    down(1);//维护堆
}

完整代码

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int h[N],cnt;

void down(int x)
{
    int t = x;
    if(x * 2 <= cnt && h[2 * x] < h[t])
        t = x * 2;
    if(x * 2 + 1 <= cnt && h[2 * x + 1] < h[t])
        t = x * 2 + 1;
    if(t != x)
    {
        swap(h[x],h[t]);
        down(t);
    }
}

int main()
{
    int n,m;
    cin >> n >> m;
    for(int i = 1;i <= n;i++)
        cin >> h[i];
    cnt = n;
    for(int i = n / 2;i >= 1;i--)
        down(i);
    while(m--)
    {
        printf("%d ",h[1]);
        h[1] = h[cnt];
        cnt--;
        down(1);
    }
    return 0;
}

应用2:模拟堆

传送门:839. 模拟堆 - AcWing题库

思路

这个题有五种操作,前三种操作都是之前讲过的,没什么说的。还是不会的话,建议复习一下上面的堆排序!!!

后面的两个操作是比较麻烦的,需要记录第k个数是哪个元素,这需要我们重新开两个数组来记录。

提前说明:ph数组来记录第k个插入的数在我们堆里面的下标是什么hp数组来记录堆里的下标第几个插入点。

由于之前的h数组只能存储数组堆元素,并不知道插入顺序,所以需要一个数组来存储堆里面的元素的下标是第几个插入的。

要ph数组大家应该都可以理解,但是为什么需要hp数组呢?

hp数组是为了交换ph数组服务的。

当我们发现两个元素需要互换的时候,首先应该互换它们的值,其次要互换记录它们第几次插入的数的两个数组(ph数组)。但是只知道次数数组(ph数组)指向哪一个元素,并不知道哪一个元素哪个次数数组指向。所以我们需要开一个hp数组存储堆里面的元素是第几个插入的元素,才能进行ph数组的交换

ph数组和hp数组是互逆关系

ph[a] = x,hp[x] = a

 交换两个数代码

void heap_swap(int a,int b)
{
    swap(h[a],h[b]);//先交换两个数的值
    swap(ph[hp[a]],ph[hp[b]]);//再交换次数数组
    swap(hp[a],hp[b]);//最后交换hp数组
}

要是还没有理解ph数组、hp数组的话,请看我的这篇文章,里面详细地解释了两个数组的作用

AcWing 839. 模拟堆(ph数组+hp数组讲解) - AcWing

代码

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 1e5 + 10;
int h[N],ph[N],hp[N],cnt,m;//数组h为堆,数组ph存储第k次插入的数,数组hp为堆里面的元素是第几次插入,cnt为堆大小,m为第几个插入的数

void heap_swap(int a,int b)
{
    swap(h[a],h[b]);//先交换这两个数的值
    swap(ph[hp[a]],ph[hp[b]]);//再交换ph数组
    swap(hp[a],hp[b]);//最后交换hp数组
}

//up操作
void up(int t)
{
    //使用迭代,当t有父节点,且比父节点小的时候,就互换
    while(t / 2 && h[t] < h[t / 2])
    {
        heap_swap(t,t / 2);
        t /= 2;//更新t
    }
}

//down操作
void down(int i)
{
    int t = i;//存的最小值
    if(i * 2 <= cnt && h[i * 2] < h[t])
        t = i * 2;
    if(i * 2 + 1 <= cnt && h[i * 2 + 1] < h[t])
        t = i * 2 + 1;
    if(t != i)
    {
        heap_swap(t,i);
        down(t);
    }
}

int main()
{
    int n;
    scanf("%d",&n);
    
    char op[5];
    int k,x;
    
    while(n--)
    {
        scanf("%s",op);
        if(!strcmp(op,"I"))
        {
            scanf("%d",&x);
            cnt++,m++;//每次存入一个数,cnt和m都要加
            h[cnt] = x;//存入堆
            ph[m] = cnt;//存入ph数组,表示第m个插入的数在堆里的下标是cnt
            hp[cnt] = m;//堆里面的下标为cnt的元素是第m个插入的数
            up(cnt);//由于是从末尾插入的所以应该使用up操作,而不是down操作
        }
        else if(!strcmp(op,"PM"))
            printf("%d\n",h[1]);//输出堆顶
        else if(!strcmp(op,"DM"))
        {
            heap_swap(1,cnt);//堆顶元素和末尾元素互换
            cnt--;//删除末尾元素,也就是删除了堆顶
            down(1);//维护堆
        }
        else if(!strcmp(op,"D"))
        {
            scanf("%d",&k);
            k = ph[k];//第k个数在堆里面的下标
            heap_swap(k,cnt);//一样的删除操作,只不过是删除第k个插入的数
            cnt--;
            up(k);//up和down只会执行一个,少写一个判断语句,直接两个操作都写上,符合什么就会执行其中的一个,另一个并不会执行
            down(k);
        }
        else
        {
            scanf("%d%d",&k,&x);
            k = ph[k];//第k个数在堆里面的下标
            h[k] = x;//修改值
            up(k);
            down(k);
        }
    }
    return 0;
}

八、哈希表

介绍

哈希表是一种新的数据结构

应用场景

哈希表最主要的作用把一个比较庞大的空间/值域映射到比较小的空间(一般来说映射到0~N,N比较小,可能为105 106这种级别 ),比较常见的为把0~10^9之间的数映射为0~10^5

哈希函数

当我们需要映射时,这时候就需要一个哈希函数,输入值为x(范围:-109~109),输出值为y(0~N),这个函数的功能就是把一个很大的区间里面的数映射到一个很小的区间

怎么操作呢?

为:x% N(N为想要的区间的最大值) 。这样就把结果控制了0~N - 1

注意:这个N是有讲究的,最好取质数,并且离2的n次幂越远越好,这样发生冲突的概率最小

处理冲突

当我们把一个很大的区间映射到很小的空间内,映射后就可能会有重复元素,这就叫做冲突

h(5) = 2,h(10) = 2;

而根据处理方式分为两种,第一种为开放寻址法,第二种为拉链法。接下来详细讲

开放寻址法

只开一个一维数组,一般开到映射值的数据范围的2~3倍,但还是比映射前的数据范围小很多的!!!

h(x) = k;

1、添加操作

就是从下标为k的数组开始找,直到找到没有元素的位置,然后存进去

2、查找操作

还是从下标为k的位置往后找,如果后面的位置有元素并且为x,说明我们找到了x;如果当前位置有人但不是x,就看下一个位置;如果当前位置没人,说明x不存在

3、删除操作

这里并不是真正的删除,而是使用一个数组标记一下,如果要删除某个元素,就标记为false

拉链法

拉链法顾名思义就是首先我们开一个一维数组来存储所有的哈希值(哈希过后的值)然后在每一个数组槽那里拉一个单链表,来存储映射前的值。

就是数字 + 单链表

1、添加操作

就是和单链表的添加一样,就不写了

2、查找操作

查找操作,就是遍历下标为映射值的数组元素下面的链表,看看是否有

3、删除操作

这里并不是真正的删除,而是使用一个数组标记一下,如果要删除某个元素,就标记为false

 应用1:模拟散列表

传送门:840. 模拟散列表 - AcWing题库

 开放寻址法代码

#include <iostream>
#include <cstring>

using namespace std;

const int N = 100003,null = 0x3f3f3f3f;//这个N是有讲究的,一般为大于数据范围的第一个素数,这样冲突概率最小
int h[N];//数组h为一维数组

int find(int x)
{
    int k = (x % N + N) % N;
    //不会死循环,因为开的的数组是实际元素的2倍
    //循环结束条件,没有元素或者有元素但不是x
    while(h[k] != null && h[k] != x)
    {
        k++;
        if(k == N)//如果找完了k后面的所有位置,那就从头找
            k = 0;
    }
    return k;//返回x的存储位置
}

int main()
{
    int n;
    cin >> n;
    
    memset(h,0x3f,sizeof h);//为什么不设置为0x3f3f3f3f?是因为是按字节设置的,
    
    char op[2];
    int x;
    while(n--)
    {
        scanf("%s%d",op,&x);
        if(*op == 'I')
        {
            int k = find(x);
            h[k] = x;
        }
        else
        {
            if(h[find(x)] != null)
                puts("Yes");
            else
                puts("No");
        }
    }
    return 0;
}

拉链法代码

#include <iostream>
#include <cstring>

using namespace std;

const int N = 100003;//这个N是有讲究的,一般为大于数据范围的第一个素数,这样冲突概率最小
int h[N],e[N],ne[N],idx;//数组h为一维数组,后面的为单链表的常用量

int 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;
}

int main()
{
    int n;
    cin >> n;
    
    memset(h,-1,sizeof h);//把没有映射到的值设置为-1,是最快的清零操作
    
    char op[2];
    int x;
    while(n--)
    {
        scanf("%s%d",op,&x);
        if(*op == 'I')
            insert(x);
        else
        {
            if(find(x))
                puts("Yes");
            else
                puts("No");
        }
    }
    return 0;
}

注意

  1. int k = (x % N + N) % N 为什么映射要这样写呢?

    为什么不直接写成int k = x % N。因为在计算机语言中负数的余数还是负数,不为正数,和数学里面的不一样。那为什么不写成int k = (x + N) % N,因为当x很大的时候,可能会爆int
  2. 为什么要定义为char op[2]?

    是因为当用scanf接受数据时,如果是字符型的数据,可能会被空格、回车、制表符等等干扰。定义为这样可以筛去这些干扰信息
  3. memset函数为最快的清零操作

memset_百度百科 (baidu.com)icon-default.png?t=N176https://baike.baidu.com/item/memset/4747579

字符串哈希方法

  • 16
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值