跳转至

基础数据结构合集

约 5238 个字 1007 行代码 13 张图片 预计阅读时间 39 分钟

md

1. 单链表

实现一个单链表,链表初始为空,支持三种操作:

向链表头插入一个数;

删除第 k 个插入的数后面的数;

在第 k 个插入的数后插入一个数。

现在要对该链表进行 M 次操作,进行完所有操作后,从头到尾输出整个链表。

注意:题目中第 k 个插入的数并不是指当前链表的第 k 个数。例如操作过程中一共插入了 n 个数,则按照插入的时间顺序,这 n 个数依次为:第 1 个插入的数,第 2 个插入的数,…第 n 个插入的数。

输入格式

第一行包含整数 M,表示操作次数。

接下来 M 行,每行包含一个操作命令,操作命令可能为以下几种:

H x,表示向链表头插入一个数 x。

D k,表示删除第 k 个插入的数后面的数(当 k 为 0 时,表示删除头结点)。

I k x,表示在第 k 个插入的数后面插入一个数 x(此操作中 k 均大于 0)。

输出格式

共一行,将整个链表从头到尾输出。

数据范围

1≤M≤100000

所有操作保证合法。

输入样例:

10

H 9

I 1 1

D 1

D 0

H 6

I 3 6

I 4 5

I 4 5

I 3 4

D 6

输出样例:

6 4 6 5

我们用-1 表示空指针。

实现一些基本的操作:

(1)初始化

头节点指向-1 表示空节点,idx=0 表示从 0 好节点进行编号。

C++
void init()//链表的初始化
{
    head=-1;//头节点指向空节点
    idx=0;
}

(2)向头节点后面插入一个新节点

(3)向第 k 个插入的点后面添加一个点同(2)

C++
void add(int k,int x)//向第k个插入的数后面插入一个数
{
    e[idx]=x,ne[idx]=ne[k],ne[k]=idx++;
}

因为是从 0 号节点进行编号的,所以第 k 个插入的点其实是第 k-1 个点 add(k-1,x);

(4)删除头节点

C++
void remove()//删除头节点
{
    head=ne[head];
}

(5)删除第 k 个插入的点

C++
void de(int k)//删除第k个插入的数
{
    ne[k]=ne[ne[k]];
}

remove(k-1);

AC 代码

C++
#include<bits/stdc++.h>
#include<string>
using namespace std;
const int N=1e6+10;
int head,e[N],ne[N],idx;

void init()//链表的初始化
{
    head=-1;
    idx=0;
}

void add_head(int x)//向头节点之后插入一个数
{
    e[idx]=x,ne[idx]=head,head=idx++;
}

void add(int k,int x)//向第k个插入的数后面插入一个数
{
    e[idx]=x,ne[idx]=ne[k],ne[k]=idx++;
}

void de(int k)//删除第k个插入的数
{
    ne[k]=ne[ne[k]];
}

void remove()//删除头节点
{
    head=ne[head];
}

int main()
{
    int t;
    scanf("%d",&t);
    init();
    while(t--){
        string op;
        int k,x;
        cin>>op;
        if(op=="H"){
            scanf("%d",&x);
            add_head(x);
        }
        else if(op=="D"){
            scanf("%d",&k);
            if(k==0) remove();
            de(k-1);
        }
        else{
            scanf("%d%d",&k,&x);
            add(k-1,x);
        }
    }
    for(int i=head;i!=-1;i=ne[i])
        cout<<e[i]<<" ";
    return 0;
}

2. 双链表

实现一个双链表,双链表初始为空,支持 55 种操作:

在最左侧插入一个数;

在最右侧插入一个数;

将第 k 个插入的数删除;

在第 k 个插入的数左侧插入一个数;

在第 k 个插入的数右侧插入一个数

现在要对该链表进行 M 次操作,进行完所有操作后,从左到右输出整个链表。

注意:题目中第 k 个插入的数并不是指当前链表的第 k 个数。例如操作过程中一共插入了 n 个数,则按照插入的时间顺序,这 n 个数依次为:第 1 个插入的数,第 2 个插入的数,…第 n 个插入的数。

输入格式

第一行包含整数 M,表示操作次数。

接下来 M 行,每行包含一个操作命令,操作命令可能为以下几种:

L x,表示在链表的最左端插入数 x。

R x,表示在链表的最右端插入数 x。

D k,表示将第 k 个插入的数删除。

IL k x,表示在第 k 个插入的数左侧插入一个数。

IR k x,表示在第 k 个插入的数右侧插入一个数。

输出格式

共一行,将整个链表从左到右输出。

数据范围

1≤M≤100000

所有操作保证合法。

输入样例:

10

R 7

D 1

L 3

IL 2 10

D 3

IL 2 7

L 8

R 9

IL 4 7

IR 2 2

输出样例:

8 7 7 3 2 9

双链表类似单链表的操作进行处理,只是每个节点都有两个指针 l[],r[],分别指向前驱和后继。

模板:

C++
// e[]表示节点的值,l[]表示节点的左指针,r[]表示节点的右指针,idx表示当前用到了哪个节点
int e[N], l[N], r[N], idx;

// 初始化
void init()
{
    //0是左端点,1是右端点
    r[0] = 1, l[1] = 0;
    idx = 2;
}

// 在节点a的右边插入一个数x
void insert(int a, int x)
{
    e[idx] = x;
    l[idx] = a, r[idx] = r[a];
    l[r[a]] = idx, r[a] = idx ++ ;
}

// 删除节点a
void remove(int a)
{
    l[r[a]] = l[a];
    r[l[a]] = r[a];
}
C++
#include<bits/stdc++.h>
#include<string>
#include<algorithm>
using namespace std;
const int N=1e6+10;
int l[N],r[N],e[N],idx;
void init()
{
    r[0]=1;
    l[1]=0;
    idx=2;
}

void add(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()
{
    init();
    int t;
    cin>>t;
    while(t--)
    {
        string op;
        cin>>op;
        int k,x;
        if(op=="R")
        {
            cin>>x;
            add(l[1],x);
        }
        else if(op=="L")
        {
            cin>>x;
            add(0,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]<<" ";
    return 0;
}

3. 栈

3.1 模拟栈

实现一个栈,栈初始为空,支持四种操作:

push x – 向栈顶插入一个数 x;

pop – 从栈顶弹出一个数;

empty – 判断栈是否为空;

query – 查询栈顶元素。

现在要对栈进行 M 个操作,其中的每个操作 3 和操作 4 都要输出相应的结果。

输入格式

第一行包含整数 M,表示操作次数。

接下来 M 行,每行包含一个操作命令,操作命令为 push x,pop,empty,query 中的一种。

输出格式

对于每个 empty 和 query 操作都要输出一个查询结果,每个结果占一行。

其中,empty 操作的查询结果为 YES 或 NO,query 操作的查询结果为一个整数,表示栈顶元素的值。

数据范围

1≤M≤100000,

1≤x≤1e9

所有操作保证合法。

输入样例:

10

push 5

query

push 6

pop

query

pop

empty

push 4

query

empty

输出样例:

5

5

YES

4

NO

栈:后进先出的数据结构。

C++
// tt表示栈顶
int stk[N], tt = 0;

// 向栈顶插入一个数
stk[ ++ tt] = x;

// 从栈顶弹出一个数
tt -- ;

// 栈顶的值
stk[tt];

// 判断栈是否为空
if (tt > 0)
{

}

AC 代码

C++
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int stk[N],tt=0;
int main()
{
    int t;
    cin>>t;
    while(t--)
    {
        string op;
        cin>>op;
        if(op=="push")
        {
            int x;
            cin>>x;
            stk[++tt]=x;
        }
        else if(op=="pop")
        {
            tt--;
        }
        else if(op=="empty")
        {
            if(tt>0)
            cout<<"NO"<<endl;
            else
            cout<<"YES"<<endl;
        }
        else if(op=="query")
        {
            cout<<stk[tt]<<endl;
        }
    }
    return 0;
}

4. 队列

实现一个队列,队列初始为空,支持四种操作:

push x – 向队尾插入一个数 x;

pop – 从队头弹出一个数;

empty – 判断队列是否为空;

query – 查询队头元素。

现在要对队列进行 M 个操作,其中的每个操作 3 和操作 4 都要输出相应的结果。

输入格式

第一行包含整数 M,表示操作次数。

接下来 M 行,每行包含一个操作命令,操作命令为 push x,pop,empty,query 中的一种。

输出格式

对于每个 empty 和 query 操作都要输出一个查询结果,每个结果占一行。

其中,empty 操作的查询结果为 YES 或 NO,query 操作的查询结果为一个整数,表示队头元素的值。

数据范围

1≤M≤100000,

1≤x≤1e9,

所有操作保证合法。

输入样例:

10

push 6

empty

query

pop

empty

push 3

push 4

pop

query

push 6

输出样例:

NO

6

YES

4

队列:先进先出。

普通队列

C++
// hh 表示队头,tt表示队尾
int q[N], hh = 0, tt = -1;

// 向队尾插入一个数
q[ ++ tt] = x;

// 从队头弹出一个数
hh ++ ;

// 队头的值
q[hh];

// 判断队列是否为空
if (hh <= tt)
{

}

循环队列

C++
// hh 表示队头,tt表示队尾的后一个位置
int q[N], hh = 0, tt = 0;

// 向队尾插入一个数
q[tt ++ ] = x;
if (tt == N) tt = 0;

// 从队头弹出一个数
hh ++ ;
if (hh == N) hh = 0;

// 队头的值
q[hh];

// 判断队列是否为空
if (hh != tt)
{

}

AC 代码

C++
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int q[N],hh=0,tt=-1;
int main()
{
    ios::sync_with_stdio(false);
    int T;
    cin>>T;
    while(T--){
        string op;
        cin>>op;
        if(op=="push"){
            int x;
            cin>>x;
            q[++tt]=x;
        }
        else if(op=="pop"){
            hh++;
        }
        else if(op=="empty"){
            if(hh<=tt) cout<<"NO"<<endl;
            else cout<<"YES"<<endl;
        }
        else cout<<q[hh]<<endl;
    }
    return 0;
}

5. 堆

C++
// h[N]存储堆中的值, h[1]是堆顶,x的左儿子是2x, 右儿子是2x + 1
// 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]);
}

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)
    {
        heap_swap(u, t);
        down(t);
    }
}

void up(int u)
{
    while (u / 2 && h[u] < h[u / 2])
    {
        heap_swap(u, u / 2);
        u >>= 1;
    }
}

// O(n)建堆
for (int i = n / 2; i; i -- ) down(i);

5.1 堆排序

输入一个长度为 n 的整数数列,从小到大输出前 m 小的数。

输入格式

第一行包含整数 n 和 m。

第二行包含 n 个整数,表示整数数列。

输出格式

共一行,包含 m 个整数,表示整数数列中前 m 小的数。

数据范围

1≤m≤n≤1e5,

1≤ 数列中元素 ≤1e9

输入样例:

5 3

4 5 1 3 2

输出样例:

1 2 3

一、堆的基本概念

堆:是一个完全二叉树。

堆分成两类,小根堆和大根堆。

小根堆:父节点小于等于左右孩子节点;

大根堆:父节点大于等于左右孩子节点。

STL 里面的堆又称为优先队列;

如何手写一个堆?

本篇文章以小根堆为例,实现堆的一些基本的操作。

我们用一维数组来维护一个堆,规定数组的下标从 1 开始,每个下标的左右儿子分别为 2*x,2*x+1;

我们先讲述堆中两个最基本的操作 down(x),up(x)两个操作。

down(x),如果我们修改堆某个节点或者删除某个节点 ,我们就需要用 down 和 up 来维护我们堆中的关系,我们以小根堆为例,如果父节点变大,那么他就要往下沉,因为我们小根堆满足父节点小于等于左右儿子,同理,up 恰好相反,如果父节点变小,它就要和自己的父节点比较,直到满足小根堆的定义为止。

二、堆的基本操作

那么我们就可以用 down 和 up 操作完成堆中最基本的操作:

1.插入一个数

我们插入一个数一般是插入到堆中最后一个数的后面再进行 up 操作。

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

2.求集合当中的最小值

因为是小根堆,我们堆顶元素是最小值。

heap[1];

3.删除最小值

我们需要删除堆顶元素,都是如果直接删除堆顶元素的话,会很麻烦,我们可以用最后一个元素来覆盖堆顶元素,如何进行 down(1)操作。

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

4.删除任意一个值

我们类似于删除堆顶元素的操作,我们先用最后一个元素的值覆盖删除元素的值,因为我们不知道覆盖后的元素是变大还是变小了,所有我们需要判断是执行 up 还是 down。

C++
int t\=heap[k];

heap[k]\=heap[size];

size--;

if(heap[k]\>t) down(k);

else up(k);

当然我们可以简化:

C++
heap[k]\=heap[size];

size--;

down(k);

up(k);

5.修改任意一个元素

C++
heap[k]\=x;

down(k);

up(k);

屏幕截图 2025-02-05 180008AC 代码

C++
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=1e5+10;
int h[N],siz;
int n,m;

void down(int u)
{
    int t=u;//t存储3个节点中的最小值,开始时假设最小值为父节点
    if(2*u<=siz&&h[2*u]<h[t]) t=2*u;//和左儿子比较
    if(2*u+1<=siz&&h[2*u+1]<h[t]) t=2*u+1;//和右儿子比较
    if(t!=u)
    {
        swap(h[t],h[u]);
        down(t);
    }
}

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

5.2 模拟堆

维护一个集合,初始时集合为空,支持如下几种操作:

I x,插入一个数 x;

PM,输出当前集合中的最小值;

DM,删除当前集合中的最小值(数据保证此时的最小值唯一);

D k,删除第 k 个插入的数;

C k x,修改第 k 个插入的数,将其变为 x;

现在要进行 N 次操作,对于所有第 2 个操作,输出当前集合的最小值。

输入格式

第一行包含整数 N。

接下来 N 行,每行包含一个操作指令,操作指令为 I x,PM,DM,D k 或 C k x 中的一种。

输出格式

对于每个输出指令 PM,输出一个结果,表示当前集合中的最小值。

每个结果占一行。

数据范围

1≤N≤1e5

−1e9≤x≤1e9

数据保证合法。

输入样例:

8

I -10

PM

I -10

D 1

C 2 8

I 6

PM

DM

输出样例:

-10

6

思路:

我们需要维护第 i 个插入的数,则需要再开两个数组维护信息;

AC 代码

C++
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=1e6+10;
int n,h[N],ph[N],hp[N],siz;

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(2*u<=siz&&h[2*u]<h[t]) t=2*u;
    if(2*u+1<=siz&&h[2*u+1]<h[t]) t=2*u+1;
    if(u!=t)
    {
        heap_swap(t,u);
        down(t);
    }
}

void up(int u)
{
    if(u/2&&h[u/2]>h[u])
    {
        heap_swap(u/2,u);
        up(u/2);
    }
}

int main()
{
    scanf("%d",&n);
    int m=0;
    while(n--)
    {
        string op;
        cin>>op;
        if(op=="I")
        {
            int x;
            scanf("%d",&x);
            m++;
            h[++siz]=x;
            ph[m]=siz;
            hp[siz]=m;
            up(siz);
        }
        else if(op=="PM") printf("%d\n",h[1]);
        else if(op=="DM")
        {
            heap_swap(1,siz);
            siz--;
            down(1);
        }
        else if(op=="D")
        {
            int k;
            scanf("%d",&k);
            k=ph[k];
            heap_swap(k,siz);
            siz--;
            down(k);
            up(k);
        }
        else
        {
            int k,x;
            scanf("%d%d",&k,&x);
            k=ph[k];
            h[k]=x;
            down(k);
            up(k);
        }
    }

    return 0;
}

6. 哈希表

C++
(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;
    }

1.什么是哈希表?

哈希表就是当范围很大时,我们可以通过哈希表将范围缩小,并快速找出一些数,如数组的下标范围是 1~1000000000,但是其中的数很少,我们可以将其映射为 1~100000,并快速找出,如原本数组下标是 500000,我们可以映射成 50,40....

2.哈希表产生的冲突

我们可以在映射的过程中,把两个数映射成为一个数,这个就是哈希表的冲突。

如何解决冲突?

有两种办法:开放寻址法和链地址法

(1)开放寻址法

我们可以先将 h[]中每个位置上的值初始化成一个很大的数,如何通过除留余数法来找到每个数映射后的地址,如果该位置上有数,那么就继续向下一个位置探测,如果探测到最后一个位置,从第 0 个位置再进行探测。

查找一个数也是类似的,如果这个数待探测的位置上有数,那么就向下一个位置探测,如果最终探测的位置上面的数为很大的数,那么查找失败,哈希表中没有该数。

(2)拉链法

拉链法不同于开放地址法的是,把每个位置看成一个单链表,如果要某个数通过除留余数法算出来的数位置上有数,不用向后探测,只需要用头插法插入到该位置上的单链表上,查找也是如此。

6.1 模拟散列表

维护一个集合,支持如下几种操作:

I x,插入一个数 x;

Q x,询问数 x 是否在集合中出现过;

现在要进行 N 次操作,对于每个询问操作输出对应的结果。

输入格式

第一行包含整数 N,表示操作数量。

接下来 N 行,每行包含一个操作指令,操作指令为 I x,Q x 中的一种。

输出格式

对于每个询问指令 Q x,输出一个询问结果,如果 xx 在集合中出现过,则输出 Yes,否则输出 No。

每个结果占一行。

数据范围

1≤N≤1e5

−1e9≤x≤1e9

输入样例:

5

I 1

I 2

I 3

Q 2

Q 5

输出样例:

Yes

No

开放寻址法

C++
#include<iostream>
#include<algorithm>
#include<cstring>
#include<cstdio>
using namespace std;
const int N=2e5+3;
const int null=0x3f3f3f3f;
int h[N];
int n;

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

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

链地址法

C++
#include <cstring>
#include <iostream>

using namespace std;

const int N = 1e5 + 3;  // 取大于1e5的第一个质数,取质数冲突的概率最小 可以百度

//* 开一个槽 h
int h[N], e[N], ne[N], idx;  //邻接表

void insert(int x) {
    // c++中如果是负数 那他取模也是负的 所以 加N 再 %N 就一定是一个正数
    int k = (x % N + N) % N;
    e[idx] = x;
    ne[idx] = h[k];
    h[k] = idx++;
}

bool find(int x) {
    //用上面同样的 Hash函数 讲x映射到 从 0-1e5 之间的数
    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 n;

int main() {
    cin >> n;

    memset(h, -1, sizeof h);  //将槽先清空 空指针一般用 -1 来表示

    while (n--) {
        string op;
        int x;
        cin >> op >> x;
        if (op == "I") {
            insert(x);
        } else {
            if (find(x)) {
                puts("Yes");
            } else {
                puts("No");
            }
        }
    }
    return 0;
}

6.2 字符串哈希表

给定一个长度为 n 的字符串,再给定 m 个询问,每个询问包含四个整数 l1,r1,l2,r2,请你判断 [l1,r1]和 [l2,r2]这两个区间所包含的字符串子串是否完全相同。

字符串中只包含大小写英文字母和数字。

输入格式

第一行包含整数 n 和 m,表示字符串长度和询问次数。

第二行包含一个长度为 n 的字符串,字符串中只包含大小写英文字母和数字。

接下来 m 行,每行包含四个整数 l1,r1,l2,r2,表示一次询问所涉及的两个区间。

注意,字符串的位置从 1 开始编号。

输出格式

对于每个询问输出一个结果,如果两个字符串子串完全相同则输出 Yes,否则输出 No。

每个结果占一行。

数据范围

1≤n,m≤1e5

输入样例:

8 3

aabbaabb

1 3 5 7

1 3 6 8

1 2 1 2

输出样例:

Yes

No

Yes

字符串前缀哈希法。

str="ABCADEFGKLM"

预处理出所有字符串的前缀的哈希

h[0]=0

h[1]="A"的哈希值

h[2]="AB"的哈希值

h[3]="ABC"的哈希值

1.如何定义某个前缀的哈希?

把字符串看成 P 进制的数。

如"ABCD"可以看成 P 进制的 1234

转化成十进制的数就是(1*p^3+2*p^2+3*p^1+4*p^0)%Q;

由于结果很大,我们模上 2^64 次方,可以直接用 unsigned long long 来存储,unsigned long long 相当于 2^64,溢出的部分就相当于取模。

注:一般不能映射成 0,比如 A->0,则 AA->00,这样就十分容易产生冲突。

前面的数字哈希会产生冲突,但是这里如果 P 取 131 或者 13331 的话,在 99.99% 的情况下不会产生冲突,则不需要进行处理冲突。

2.好处就是可以快速的求[l,r]子串的哈希值,判断两个子串是否相等。

前缀和公式 h[i+1]=h[i]×P+s[i] i∈[0,n−1] h 为前缀和数组,s 为字符串数组;

区间和公式 h[l,r]=h[r]−h[l−1]×P^(r−l+1);

C++
#include <iostream>
#include <algorithm>

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];

ULL get(int l, int r)
{
    return h[r] - h[l - 1] * p[r - l + 1];
}

int main()
{
    scanf("%d%d", &n, &m);
    scanf("%s", str + 1);

    p[0] = 1;
    for (int i = 1; i <= n; i ++ )
    {
        h[i] = h[i - 1] * P + str[i];
        p[i] = p[i - 1] * P;
    }

    while (m -- )
    {
        int l1, r1, l2, r2;
        scanf("%d%d%d%d", &l1, &r1, &l2, &r2);

        if (get(l1, r1) == get(l2, r2)) puts("Yes");
        else puts("No");
    }

    return 0;
}

7. 树和图的一些预备知识

树与图的存储

树是一种特殊的图,与图的存储方式相同。

对于无向图中的边 ab,存储两条有向边 a->b, b->a。

因此我们可以只考虑有向图的存储。

n:点数,m:边数

稀疏图:如果 m 和 n 是一个级别的,用邻接表。

稠密图:如果 m 和 n^2 是一个级别的,用邻接矩阵。

(1) 邻接矩阵:g[a][b] 存储边 a->b,先初始化 g 位正无穷

C++
memset(g,0x3f,sizeof g);
g[a][b]=c;

(2) 邻接表:

C++
// 对于每个点k,开一个单链表,存储k所有可以走到的点。h[k]存储这个单链表的头结点
int h[N], e[N], ne[N], idx;

// 添加一条边a->b
void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

// 初始化
idx = 0;
memset(h, -1, sizeof h);//初始化表头

(1) 深度优先遍历

时间复杂度 O(n+m) ,n 表示点数,m 表示边数.

C++
int dfs(int u)
{
    st[u] = true; // st[u] 表示点u已经被遍历过

    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j]) dfs(j);
    }
}

(2) 宽度优先遍历

C++
queue<int> q;
st[1] = true; // 表示1号点已经被遍历过
q.push(1);

while (q.size())
{
    int t = q.front();
    q.pop();

    for (int i = h[t]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j])
        {
            st[j] = true; // 表示点j已经被遍历过
            q.push(j);
        }
    }
}

7.1 树的深度优先遍历

树和图的深度优先遍历的模板:

C++
// 需要标记数组st[N],  遍历节点的每个相邻的便
void dfs(int u) {
    st[u] = true; // 标记一下,记录为已经被搜索过了,下面进行搜索过程
    for (int i = h[u]; i != -1; i = ne[i]) {
        int j = e[i];
        if (!st[j]) {
            dfs(j);
        }
    }
}

7.1.1 树的重心

给定一颗树,树中包含 n 个结点(编号 1∼n)和 n−1 条无向边。

请你找到树的重心,并输出将重心删除后,剩余各个连通块中点数的最大值。

重心定义:重心是指树中的一个结点,如果将这个点删除后,剩余各个连通块中点数的最大值最小,那么这个节点被称为树的重心。

输入格式

第一行包含整数 n,表示树的结点数。

接下来 n−1 行,每行包含两个整数 a 和 b,表示点 a 和点 b 之间存在一条边。

输出格式

输出一个整数 m,表示将重心删除后,剩余各个连通块中点数的最大值。

数据范围

1≤n≤1e5

输入样例

9

1 2

1 7

1 4

2 8

2 5

4 3

3 9

4 6

输出样例:

4

每次算出他下面的 size 和 n-size 进行比较即可。

AC 代码

C++
#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 1e5 + 10; //数据范围是10的5次方
const int M = 2 * N; //以有向图的格式存储无向图,所以每个节点至多对应2n-2条边

int h[N]; //邻接表存储树,有n个节点,所以需要n个队列头节点
int e[M]; //存储元素
int ne[M]; //存储列表的next值
int idx; //单链表指针
int n; //题目所给的输入,n个节点
int ans = N; //表示重心的所有的子树中,最大的子树的结点数目

bool st[N]; //记录节点是否被访问过,访问过则标记为true

//a所对应的单链表中插入b  a作为根 
void add(int a, int b) {
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

// dfs 框架
/*
void dfs(int u){
    st[u]=true; // 标记一下,记录为已经被搜索过了,下面进行搜索过程
    for(int i=h[u];i!=-1;i=ne[i]){
        int j=e[i];
        if(!st[j]) {
            dfs(j);
        }
    }
}
*/

//返回以u为根的子树中节点的个数,包括u节点
int dfs(int u) {
    int res = 0; //存储 删掉某个节点之后,最大的连通子图节点数
    st[u] = true; //标记访问过u节点
    int sum = 1; //存储 以u为根的树 的节点数, 包括u,如图中的4号节点

    //访问u的每个子节点
    for (int i = h[u]; i != -1; i = ne[i]) {
        int j = e[i];
        //因为每个节点的编号都是不一样的,所以 用编号为下标 来标记是否被访问过
        if (!st[j]) {
            int s = dfs(j);  // u节点的单棵子树节点数 如图中的size值
            res = max(res, s); // 记录最大联通子图的节点数
            sum += s; //以j为根的树 的节点数
        }
    }

    //n-sum 如图中的n-size值,不包括根节点4;
    res = max(res, n - sum); // 选择u节点为重心,最大的 连通子图节点数
    ans = min(res, ans); //遍历过的假设重心中,最小的最大联通子图的 节点数
    return sum;
}

int main() {
    memset(h, -1, sizeof h); //初始化h数组 -1表示尾节点
    cin >> n; //表示树的结点数

    // 题目接下来会输入,n-1行数据,
    // 树中是不存在环的,对于有n个节点的树,必定是n-1条边
    for (int i = 0; i < n - 1; i++) {
        int a, b;
        cin >> a >> b;
        add(a, b), add(b, a); //无向图
    }

    dfs(1); //可以任意选定一个节点开始 u<=n

    cout << ans << endl;

    return 0;
}

7.2 树的广度优先遍历

7.2.1 图中点的层次

给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环。

所有边的长度都是 1,点的编号为 1∼n。

请你求出 1 号点到 n 号点的最短距离,如果从 1 号点无法走到 n 号点,输出 −1。

输入格式

第一行包含两个整数 n 和 m。

接下来 m 行,每行包含两个整数 a 和 b,表示存在一条从 a 走到 b 的长度为 1 的边。

输出格式

输出一个整数,表示 1 号点到 n 号点的最短距离。

数据范围

1≤n,m≤1e5

输入样例:

4 5

1 2

2 3

3 4

1 3

1 4

输出样例:

1

C++
#include<iostream>
#include<algorithm>
#include<cstring>
#include<cstdio>
using namespace std;
const int N=1e6+10;

//邻接表表示方法
int h[N],e[N*2],ne[N*2],idx;
int n,m;
int d[N];//标记距离1号点的最短距离
bool st[N];//标记访问标志
int q[N];//定义一个队列

//从a->b连接一条边
void add(int a,int b)
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}

int bfs()
{
    memset(d,-1,sizeof d);
    int hh=0,tt=-1;
    d[1]=0;
    q[++tt]=1;//从1号点开始搜索
    st[1]=true;
    while(hh<=tt)
    {
        int t=q[hh++];
        for(int i=h[t];i!=-1;i=ne[i])//访问该点的邻接点
        {
            int j=e[i];
            if(!st[j])
            {
                d[j]=d[t]+1;
                q[++tt]=j;
                st[j]=true;
            }
        }
    }
    return d[n];
}

int main()
{
    memset(h,-1,sizeof h);//初始化邻接表头
    scanf("%d%d",&n,&m);
    while(m--)
    {
        int a,b;
        scanf("%d%d",&a,&b);
        add(a,b);
    }
    printf("%d\n",bfs());
    return 0;
}

8. 二叉树

C++
#include<bits/stdc++.h>
using namespace std;

const int N = 1e5;
int lson[N], rson[N], val[N];
int tot; // 当前节点总数

// 创建新节点
int newNode(int x) {
    val[tot] = x;
    lson[tot] = rson[tot] = -1; // 初始化左右子节点为空
    return tot++; // 返回新节点编号
}

// 插入节点
void insert(int& root, int x) {
    if (root == -1) { // 如果树为空,则创建新节点
        root = newNode(x);
        return;
    }
    // 根据值大小插入左右子树
    if (x < val[root]) insert(lson[root], x);
    else insert(rson[root], x);
}

// 中序遍历树
void inorder(int root) {
    if (root == -1) return; // 如果节点为空,直接返回
    inorder(lson[root]); // 递归遍历左子树
    printf("%d ", val[root]); // 输出当前节点值
    inorder(rson[root]); // 递归遍历右子树
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0); // 提高输入输出效率
    int root = -1; // 初始化树为空
    insert(root, 10);
    insert(root, 20);
    insert(root, 5);
    insert(root, 15);

    inorder(root); // 输出中序遍历结果
    puts("");

    return 0;
}

9. Trie

9.1 Trie 字符串统计

维护一个字符串集合,支持两种操作:

I x 向集合中插入一个字符串 x;

Q x 询问一个字符串在集合中出现了多少次。

共有 N 个操作,所有输入的字符串总长度不超过 1e5,字符串仅包含小写英文字母。

输入格式

第一行包含整数 N,表示操作数。

接下来 N 行,每行包含一个操作指令,指令为 I x 或 Q x 中的一种。

输出格式

对于每个询问指令 Q x,都要输出一个整数作为结果,表示 xx 在集合中出现的次数。

每个结果占一行。

数据范围

1≤N≤2∗1e4

输入样例:

5

I abc

Q abc

Q ab

I ab

Q ab

输出样例:

1

0

1

一.Trie 树的原理

1.Trie 树的作用

快速地查询某个字符串在集合中出现的次数,高效地存储和查找字符串,时间复杂度可以达到 O(n)。

2.实现思路

类似于树的形式,将字符串存储起来,如果存在以某个字符结尾的字符串,我们就进行标记次数,方便查找字符串出现的次数。

我们把小写字母或者大写字母映射成 0-25 进行创建 Trie 树。

3.各个变量代表的意思

儿子数组 son[p][j]:存储从节点 p 沿着 j 这条边走的子节点。边为 26 个小写的字母(a-z)对应的映射值 0-25,每个节点最多可以有 26 个分支。

例如,son[0][2]=1,son[1][2]=0.

计数数组 cnt[p]:存储以 p 结尾字符串出现的次数。

节点编号 idx:来给节点进行编号。

二.建 Trie 树

1.过程

(1)空的 Trie 树只有一个节点,节点编号为 0.

(2)从根开始进行插入,枚举字符串的每个字符,如果有儿子,p 指针走到儿子,如果没有儿子,先创建儿子,p 指针再走向儿子。

(3).在单词的结尾记录插入的次数。

2.图解过程

3.代码展示

C++
void insert(char str[])
{
    int p=0;//从根开始遍历
    for(int i=0;str[i];i++)//沿着字符串一直走
    {
        int j=str[i]-'a';//映射成分支
        if(!son[p][j]) son[p][j]=++idx;//如果没有这个节点,创建节点
        p=son[p][j];//令p走向该节点
    }
    cnt[p]++;//记录次字符串出现的次数
}

三.查询 Trie

1.过程

(1).从根开始查询,对字符串进行扫描。

(2).有字符串 str[i],则走到下一个节点,走到字符串尾,返回插入的次数。

(3).没有字符串 str[i],返回 0.

2.代码展示

C++
int query(char str[])
{
    int p=0;//从根开始
    for(int i=0;str[i];i++)  
    {
        int j=str[i]-'a';
        if(!son[p][j]) return 0;//不存在节点,返回0
        p=son[p][j];
    }
    return cnt[p];//返回字符串的次数
}

AC 代码

9.2 最大异或对

在给定的 N 个整数 A1,A2……AN 中选出两个进行 xor(异或)运算,得到的结果最大是多少?

输入格式

第一行输入一个整数 N。

第二行输入 N 个整数 A1~AN。

输出格式

输出一个整数表示答案。

数据范围

1≤N≤1e5,

0≤Ai\<2^31,

输入样例:

3

1 2 3

输出样例:

3

1.思路

我们首先考虑遍历枚举的方法,然后通过发现某些性质去优化它。

C++
int res=0;
for(int i=1;i<=n;i++)
    for(int j=1;j<=n;j++)
        res=max(res,a[i]^a[j]);
cout<<res<<endl;

显然暴力的方法为 O(n2),会超时。

我们发现异或(^)的性质为二进制表示中,两个数某一位进行异或, 相同为 0,不同为 1,如果二进制 100,我们首先考虑 011,因为只有不同的位时,得到的值才能最大,我们可以用 trie 树从高位往低位存储,如果找某一个数的最大值时,我们应该首先考虑它对于二进制某一位不同的值是否存在,如果存在,我们沿着这个分支走到 下一个节点,如果不存在,只能走和他相同的分支。

说明

用 Trie 存储单词,由 26 个字母构成的 Trie 树,是一颗 26 叉树,26 个字母构成分支,深度为最长单词的长度。

用 Trie 存储整数,由整数的十进制位构成的 Trie,是一颗 10 叉树,0-9 个数字构成分支,深度为 10 层。

用 Trie 存储整数,由整数的二进制位构成的 Trie,是一颗二叉树,0 和 1 构成分支,深度为 31 层。

  1. 图解

C++
int res=0;
for(int i=1;i<=n;i++)
    for(int j=1;j<=n;j++)
        res=max(res,a[i]^a[j]);
cout<<res<<endl;

AC 代码

C++
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=1e6+10,M=30000000;
int n,a[N],son[M][2],idx;

void insert(int x)//和字典树一样的思路
{
    int p=0;
    for(int i=30;i>=0;i--)//从二进制的最高位开始建树
    {
        int j=x>>i&1;//取出该位置的二进制表示的数
        if(!son[p][j]) son[p][j]=++idx;
        p=son[p][j];
    }
}

int query(int x)
{
    int res=0,p=0;
    for(int i=30;i>=0;i--)
    {
        int j=x>>i&1;
        if(son[p][!j])//如果存在某个节点和x该位置的二进制数不相同的话,说明异或结果为1,加上这一个二进制位对应十进制的数值,让p走到下一个节点
        {
            res+=1<<i;
            p=son[p][!j];
        }
        else p=son[p][j];//否则只能走相等的分支,说明异或结果为0,即res+=0<<i,因为0<<i的结果为0,所有可以省略,p走到下一个节点
    }
    return res;
}

int main()
{
    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,query(a[i]));
    cout<<res<<endl;
    return 0;
}

10. 线段树

C++
#include <bits/stdc++.h>
using namespace std;

const int N = 1e5 + 10;
int seg[4 * N], arr[N];

// 建树:构建线段树
void build(int node, int l, int r) {
    if (l == r) {
        seg[node] = arr[l];
    } else {
        int mid = (l + r) / 2;
        build(2 * node, l, mid);
        build(2 * node + 1, mid + 1, r);
        seg[node] = seg[2 * node] + seg[2 * node + 1];  // 区间和
    }
}

// 区间查询:查询区间 [l, r] 的和
int query(int node, int l, int r, int ql, int qr) {
    if (qr < l || r < ql) return 0;  // 无交集
    if (ql <= l && r <= qr) return seg[node];  // 完全包含
    int mid = (l + r) / 2;
    return query(2 * node, l, mid, ql, qr) + query(2 * node + 1, mid + 1, r, ql, qr);  // 合并结果
}

// 单点更新:更新位置 idx 的值为 val
void update(int node, int l, int r, int idx, int val) {
    if (l == r) {
        arr[idx] = val;
        seg[node] = val;
    } else {
        int mid = (l + r) / 2;
        if (idx <= mid) update(2 * node, l, mid, idx, val);
        else update(2 * node + 1, mid + 1, r, idx, val);
        seg[node] = seg[2 * node] + seg[2 * node + 1];  // 更新当前节点
    }
}

int main() {
    int n, q;
    cin >> n >> q;
    for (int i = 1; i <= n; i++) cin >> arr[i];

    build(1, 1, n);  // 初始化线段树

    while (q--) {
        int op;
        cin >> op;
        if (op == 1) {
            int l, r;
            cin >> l >> r;
            cout << query(1, 1, n, l, r) << endl;
        } else if (op == 2) {
            int idx, val;
            cin >> idx >> val;
            update(1, 1, n, idx, val);
        }
    }

    return 0;
}
  • l​, r​ 表示区间的左右端点。
  • node​ 表示当前线段树节点。
  • ql​, qr​ 表示查询区间的左右端点。
  • idx​, val​ 表示更新位置和更新的值。