Trie 树、并查集、堆

Trie 树

也叫字典树

Tire 树基本的作用:用来高效地存储和查找字符串集合的数据结构

Tire 树是如何存储字符串的?

假设要在 Tire 树里面存储这样一堆单词:凡是用到 Tire 树的题目的字符串要么全是小写字母、要么是大写字母、要么是数字,要么是 0 和 1,字母的类型不会很多

Trie 树有一个根节点,从根节点开始,从前往后依次遍历每一个字符,把每一个字符都存储下来

从根节点开始,看一下根节点有没有 a 这个点作为子节点,如果没有的话就把 a 节点创建出来,再从 a 开始走,a 的下一个字母是 b,看 a 有没有 b 这个子节点,如果没有的话就把它创建出来,以此类推. . .最终 Trie 树的形态如下:

一般在存储 Trie 树的时候,会在每个单词结尾的地方打上一个标记,表示以这个字母结尾的节点是有一个单词的

为什么要打上标记?下面举一个简单的例子来说明:

假设现在需要插入一个新的单词:abc ,得标记一下有一个单词以 c 结尾,才可以最后把它检测出来,如果我们不标记出来,走到 c 的时候,就并不知道它有没有以 c 结尾的单词,把所有结尾的节点的位置标记一下→ 表示以当前点结尾的是有一个单词的

Trie 树的查找过程

可以高效查找某一个单词在整个集合中是否出现过,并且出现多少次

假设想查找 aced,从根节点开始走,走到字母 d 的时候,单词遍历完成,d 上有一个标记,表示存在一个单词以 d 结尾,就可以说在前面这个集合里面是存在 aced 的

假设查找一个不存在的单词:abcf,从第一个字母开始,先走到 a,再走到 b,再走到 c,下一步走到 f,发现 f 不存在,没有路径了,就说明整个树中不存在 f 这个单词

假设想查找 abcd,虽然 abcd 这个单词结束的时候整个路径上的点都存在,但是 d上是没有标记的,就说明不存在一个单词以 d 结尾的

数据范围比较小:10^5

经典的 Trie 树操作,一个是存储一个字符串,一个是查询一个字符串出现多少次(插入一次就算出现一次)

最多只有 10000 个字母,节点个数最多是 10000

由于题目给出的字符串仅包含小写英文字母,因此每个节点最多只会向外连 26 条边,每一个节点的子节点的个数就是 26

下标是 0 的点,既是根节点,又是空节点:如果一个点没有子节点也会让它指向 0

本来是一个字母,需要把小写字母 a ~ z 映射成 0 ~ 25

插入的过程:

从根节点开始走,如果当前点上不存在对应的字母,就需要把它创建出来

有路就走过去,没有路的话,创建一条路也要走过去

结束的时候 p 对应的点就是最后一个点,假设要插入 aced,p 就是 d 这个点,让这个点上的 cnt ++,表示以这个点结尾的单词数量多了一个

算法题中凡是用 Trie 树做的题目,题目一定限制了字母的种类只有 26 种或者 52 种,不会特别多

Trie 树就是数组模拟指针

x(下标是 x 的点)

son[x][1](存储 x 这个节点所有的儿子:son[x][0]:x 的第 0 个儿子,son[x][1]:x 的第 1 个儿子)

cnt[x] (存储以 x 结尾的单词数量有多少个)

idx→ 0(存储当前用到哪一个下标,一开始为空,只有根节点,idx 指向 0,当我们插入一个单词的时候,会给它分配一个下标,例如,插入一个节点,给它分配 1,表示新插入的点是 1 号下标,2 . . . 3,以此类推,和单链表里面的 idx 是一个东西)

不管是汉字还是英文字母,在计算机中存储的都是一个二进制的字符串 "1010010",如果原题目中的字母个数特别多或者是汉字,可以用二进制来存储,每一位只有两种选择,0 或者 1

#include <iostream>

using namespace std;

const int N = 100010;
//son存储Trie树中每个节点的所有儿子 cnt存储以当前点结尾的单词有多少个 
//idx和单链表中的idx是一样的 存储当前用到哪一个下标
int son[N][26],cnt[N],idx;

//字符串
char str[N];

//存储/插入操作
void insert(char str[])
{
    //从根节点开始
    int p = 0;
    //从前往后遍历整个字符串:c++ 中字符串结尾是'\0' str[i]可以用来判断是不是走到结尾
    for(int i = 0;str[i];i++ )
    {
        //每次把当前字母对应的子节点编号搞出来:把a-z映射成0-25
        int u = str[i] - 'a';
        //如果p这个节点不存在u这个儿子 就把它创建出来
        if(!son[p][u]) son[p][u] = ++idx;
        //当前点一定有下一个点了:如果没有也被创建出来了-> 走到下一个点
        p = son[p][u];
    }
    //结束的时候 p 对应的点就是最后一个点 让这个点上的 cnt ++,表示以这个点结尾的单词数量多了一个
    cnt[p] ++;
}

//查询操作:返回这个字符串出现多少次
int query(char str[])
{
    //从根节点开始
    int p  = 0;
    //从前往后遍历整个字符串:c++ 中字符串结尾是'\0' str[i]可以用来判断是不是走到结尾
    for(int i = 0;str[i];i++ )
    {
        //每次把当前字母对应的子节点编号搞出来
        int u = str[i] - 'a';
        //如果p这个节点不存在u这个儿子 说明集合中不存在这个单词
        if(!son[p][u]) return 0;
        //否则就走过去
        p = son[p][u];
    }
    //返回以p结尾的单词数量
    return cnt[p];
}

int main()
{
    int n;
    //读入一个n
    scanf("%d",&n);
    //一共有n个操作
    while(n-- )
    {
        //读入操作类型
        char op[2];
        //读入要操作的字符串
        scanf("%s%s",op,str);
        //如果操作类型是插入的话就把它插进去
        if(op[0] == 'I') insert(str);
        //否则就是查询操作 直接输出
        else printf("%d\n",query(str));
    }
    return 0;
}

并查集

并查集的三种应用:朴素并查集、维护每个集合里面元素数量的并查集、记录偏移量的并查集

快速处理以下问题(应用场景):
1.将两个集合合并

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

暴力做法:

用一个数组 belong[x] 来存储每一个元素 x 属于哪一个集合( belong[ x ] = a,表示 x 属于集合 a )

对于操作 2:想看一下两个元素是否在一个集合中可以快速判断,if ( belong[ x ] == belong[ y ] ),时间复杂度为 O(1)

如果想合并两个集合:例如,想合并的第一个集合有 1000 个元素,想合并的第二个集合有 2000 个元素,要么是把这 1000 个元素属于的集合的编号改成另外一种,或者把 2000 个元素属于的集合编号改变,不管改变哪一个,至少需要 1000 次计算,暴力做法维护这两个操作是非常耗时的

并查集可以在近乎 O(1) 的时间复杂度内完成这两个操作

基本原理:

用树的形式维护所有的集合(注意:不一定是一棵二叉树),每个集合用一棵树来表示,根节点的编号就是当前这个集合的编号,对于每一个节点,都存储它的父节点,用 p[x] 来表示 x 的父节点

当我们想求某一个点属于哪个集合的时候,可以先找到这个的父节点,看一下这个点的父节点是不是树根,如果不是树根的话,就再往上找,直到找到树根为止,当前这个元素属于的集合的编号就是树根的编号,因此可以用这样的方式快速找到每一个元素是属于哪一个集合的

问题 1:

如何判断一个点是不是树根?if( p[x] == x ),树根等于 x,根节点的编号就是当前这个集合的编号,除了根节点之外,p[x] 都不等于 x

问题 2:

如何求 x 的集合编号:从 x 一路往上走,走到树根即可,while(p[x] != x) x = p[x];最后一个 x 的值就是集合编号(只要 x 不是树根,就一直往上走,直到走到树根为止)

实际上就是操作 2:求 x 和 y 是不是在同一个集合中,就分别求 x 的编号和 y 的编号,如果编号一样,说明在一个集合中,如果编号不一样,说明不在一个集合中

求 x 的集合编号这部分的时间复杂度还是很高,每次都需要从当前这个点遍历到根节点,遍历的次数和树的高度成正比)

并查集的优化:(路径压缩)

一旦 x 往上走,最终找到了根节点的位置,找到根节点之后,就把整个路径上的所有点都直接指向根节点(只会搜索一遍,假设第 1 次求 x 的根节点需要走 3 步,第 2 次取就只需要走 1 步)

优化后,时间复杂度近乎 O(1)

递归回溯的过程:

find()函数的优化

如果当前节点 x 不是根节点的话,返回 px = find( p [ x ] ),也就是返回父节点的祖宗节点,就是往上走了一层,每次只要当前节点不是根节点,就会往上走一层,直到走到根节点为止

递归在回溯的时候,就会让整个路径上所有的点都会指向祖宗节点,起到路径压缩的作用

问题 3:

如何合并两个集合:假设想把左边这个集合和右边这个集合合并,加一条边就可以了,要么是把左边这棵树插到右边这棵树的某一个位置,或者是把右边这棵树插到左边这棵树的某一个位置,通过这样的方式来合并两个集合

假设 p[x] 是 x 的集合编号,p[y] 是 y 的集合编号,合并两个集合:p[x] = y,把 x 直接插到 y 上去即可(如图 2 所示)

 

一开始有 n 个数,编号 1 ~ n,一开始每一个数都各自在一个集合当中,现在要进行 n 个操作,每个操作是两种,第一种操作类型是将编号是 a 和 b 的两个数所在的集合合并,如果两个数已经在同一个集合当中,这个操作就要被忽略;第 2 种操作类型是询问编号是 a 和 b 的两个数是不是在同一个集合当中  

初始的时候,每个元素是单独一个集合,每一个集合里面只有一个点,这个点的树根就是自己,当 p[ x ] = x 的时候,x 就是树根

一开始两个集合都是独立的,pa 是节点 a 的祖宗节点,pb 是节点 b 的祖宗节点,如果想把这两个集合合并,就让 a 的祖宗节点直接插到 b 这个点下面去,构成的图形就是上面这个集合

为什么使用 char op[2]?

scanf() 的缺点:如果写成 char op;使用 %c 的话,scanf() 会读入一些空格或者回车等莫名其妙的字符

scanf() 读入字符串会自动忽略空格和回车

如果用 scanf() 读入一个字符,建议读成字符串的形式,可以帮助我们过滤空格和回车

为了避免踩坑,建议用字符串来读入字母

#include <iostream>

using namespace std;

const int N = 100010;
int n,m;
//定义father数组 存储每个元素的父节点是谁
int p[N];

//并查集的核心操作:find(x)返回x所在集合的编号/返回x的祖宗节点+路径压缩
int find(int x)
{
    //如果p[x]!=x 说明x不是根节点 就让它的父节点等于它的祖宗节点
    if(p[x] != x) p[x] = find(p[x]);
    //最后返回父节点
    return p[x];
}

int main()
{
    //读入点的数量和操作的数量
    scanf("%d%d",&n,&m);
    //初始化的时候把所有节点的p值赋值成自己
    for(int i = 1;i <= n;i++ ) p[i] = i;
    //读入m个操作
    while(m-- )
    {    
        //每一个操作有两个数
        char op[2];
        int a,b;
        //读入两种操作
        scanf("%s%d%d",op,&a,&b);
        //合并两个集合 p[x] = y
        //find(a)返回a的祖宗节点 find(b)返回b的祖宗节点:让a的祖宗节点的父亲等于b的祖宗节点
        if(op[0] == 'M') p[find(a)] = find(b);
        //判断两个节点是不是在一个集合中
        else
        {
            //如果两个祖宗节点一样 说明两个节点在同一个集合中
            if(find(a) == find(b)) puts("Yes");
            else puts("No");
        }
    }
    return 0;
}

并查集的拓展情况

刚刚的并查集是最简单的并查集,什么额外信息都没有,只是简单地用一些树来维护每一个集合,可以快速合并两个集合,并且快速判断两个点是不是在同一个集合当中,如果想维护一些额外的信息:例如我们想维护每一个集合中的元素个数应该怎么处理,在合并两个集合的时候,想动态知道每一个集合当前有多少个元素

可以在并查集的基本操作的过程中维护一些额外的变量,就可以让整个过程维护一些额外的信息

第一个操作:在点 a 和点 b 之间连一条边;如果这两个点是同一个点,就连成一个自环,如果连接的时候,可能当前两个点已经有一条边了,就再连接一条边

第二个操作:询问两个点是不是在一个连通块中,如果从 a 可以走到 b,并且 b 也可以走到 a 的话,就说这两个点是在同一个连通块当中(就是拿边连起来的块状区域)

第三个操作:询问某一个点所在的连通块当中点的数量(右边红色点所在连通块有 3 条边,答案是 3;左边红色点所在连通块有 1 条边,答案是 1)

手动模拟样例:首先一共有 5 个 点,第一步在 1 和 2 之间连一条边;第二步询问 1 和 2 是不是在一个连通块当中(显然是在一个连通块当中,因此返回 Yes);第三步询问 1 号点所在连通块当中点的数量(因此返回 2);第四步在 2 和 5 之间连一条边;第五步询问 5 所在的连通块当中点的数量(因此返回 3)

 

除了第 3 个操作,前 2 个操作和上一题是一样的,可以用一个集合来维护连通块,一个连通块中的点就在一个集合当中

当我们在两个集合当中连一条边的时候,起到的作用就是把两个集合合并(例如在 2、4 之间连一条边,起到的作用就是把两个集合合并)

多了一个额外的操作:统计一下每一个集合里面点的数量

size 存储每一个集合里面的元素个数,规定只有根节点的 size 有意义

每一个集合里面都是一棵树,只保证根节点的 size 是有意义的即可

 

当我们在合并两棵树的时候,是把其中一棵树的根节点直接插在另一棵树的根节点的下面,插完之后,b 点就是两棵树最终的根节点,b 点应该如何更新?(合并之后,b 是两个集合的根节点,只需要维护 b 这个点的值即可,b 原本是 size[ b ] 个点,然后又加入了 size[ a ] 个点,插入后只需要把 b 更新成 size[ b ] + size[ a ] 即可)

#include <iostream>

using namespace std;

const int N = 100010;
int n,m;
//定义father数组 存储每个元素的父节点是谁
int p[N],size[N];
//需要额外统计一个变量size表示每一个集合里面点的数量  

//并查集的核心操作:find(x)返回x所在集合的编号/返回x的祖宗节点+路径压缩
int find(int x)
{
    //如果p[x]!=x 说明x不是根节点 就让它的父节点等于它的祖宗节点
    if(p[x] != x) p[x] = find(p[x]);
    //最后返回父节点
    return p[x];
}

int main()
{
    //读入点的数量和操作的数量
    scanf("%d%d",&n,&m);
    //初始化的时候把所有节点的p值赋值成自己
    for(int i = 1;i <= n;i++ ) 
    {
        p[i] = i;
        //一开始的时候size=1每一个集合只有一个点
        size[i] = 1;
    }
    //读入m个操作
    while(m-- )
    {    
        //每一个操作有两个数
        char op[5];
        int a,b;
        scanf("%s",op);
        //合并两个集合 p[x] = y
        //find(a)返回a的祖宗节点 find(b)返回b的祖宗节点:让a的祖宗节点的父亲等于b的祖宗节点
        if(op[0] == 'C') 
        {
            scanf("%d%d",&a,&b);
            //特判:如果a和b已经在同一个集合中 后面的操作就不用再继续进行
            if(find(a) == find(b)) continue;
            //在合并的时候 在新的根节点上加上当前这棵树里面数的个数即可
            size[find(b)] += size[find(a)];
            p[find(a)] = find(b);
        }
        //"Q1"询问两个点是不是在同一个集合当中
        else if(op[1] == '1')
        {
            scanf("%d%d",&a,&b);
            //如果两个祖宗节点一样 说明两个节点在同一个集合中
            if(find(a) == find(b)) puts("Yes");
            else puts("No");
        }
        //"Q2"询问某一个点所在集合当中点的数量
        else
        {
            scanf("%d",&a);
            //统计某一个点所在集合中点的个数:先找到根节点然后直接返回find(a)的size
            printf("%d\n",size[find(a)]);
        }
    }
    return 0;
}

如果 a 和 b 已经在同一个集合中,让 size[ find( b ) ] += size[ find( a ) ];等价于让整个集合的元素数量翻倍,肯定不满足要求;如果 a 和 b 已经在同一个集合中,不需要做任何操作

在做并查集操作的时候,可以同时维护一下每个集合当中元素的数量,只要在初始化的时候先把每一个集合里面的元素个数赋值为 1 即可

按秩合并:由树的高度决定(按照高度来合并),倾向于让高度比较低的树接到高度比较高的树上面

手写一个堆该如何实现?

STL 中的堆就是优先队列,且不支持删除任意一个元素和修改任意一个元素的操作,自己实现的堆可以有这样的操作

用于迪杰斯特拉算法

堆是用于维护一个数据集合的:

1.插入一个数

2.求集合当中的最小值

3.删除最小值

5.删除任意一个元素

6.修改任意一个元素

堆的基本结构

堆是一棵完全二叉树,除了最后一层节点之外,上面所有的节点都是满的状态,不存在某个点是空的情况,最后一层节点是从左到右依次排布的

以小根堆为例:数据结构 --- c语言堆的实现_小雪菜本菜的博客-CSDN博客_c语言实现堆

堆满足一个性质:每一个点都是小于等于左右儿子的(这是一个递归定义),可以发现,根节点就是整个堆里面的最小值(从叶节点开始看,从下往上看的时候,每个点都是以这个点为根的子树里面的最小值,由于根节点小于左、右两边的最小值,因此根节点就是最小值)

堆的存储

用一个一维数组存储一棵树,1 号点是根节点. . . 节点 x 的左儿子是 2x,节点 x 的右儿子是 2x + 1,注意是下标

刚刚提到的堆的 5 个操作,完全可以用如下 2 个操作组合起来

往下调整 down(x):把一个节点往下移

现在有如上图的一个堆,现在把根节点的值改变,看根节点是否还是满足堆的定义,假设把根节点的值换成 6,根节点的值变大了,需要把它往下移,移的过程中是按照什么样的逻辑关系来移动的呢?

从 3 个点中找到一个最小值 3,然后把 6 和 3 交换位置(为什么把 3 交换上来呢?假设把 4 交换到根节点的位置,3 比 4 小,不满足堆的结构,在这 3 个点中,堆顶一定是最小值,因为我们往下移的时候,一定是跟当前这 3 个点里面的最小值交换)

移动后发现 6 还是太大了,6 再跟着 3 个点里面的最小值交换,6 和 3交换位置

当交换到不能交换的时候,整棵树就又变成一个堆了

down(x) 操作对应的就是:如果把某一个点的值变大了,就要把它往下移动,由于堆是一个小根堆,变大之后就要往下沉,越往上的数越小,越大的应该越往下压

往上调整 up(x):把一个节点往上移

如果把一个数变小了,变小之后就有可能可以往上走,上面的数都是小的,下面的数都是大的,up 操作就是看一下这个数该往上走到什么时候

把 5 变成 2,变之前,根节点小于等于两个子节点,只有右边的子节点变化了,左边的点一定不会参与进来,每次往上走只需要和它的节点比较(不需要比较左节点),如果比父节点小的话,就把当前点和父节点进行交换

2 还是比 3 要小,再把 2 和 3 交换

 

如何用 up 操作和 down 操作组合出以上 5 种操作

用 heap 来表示堆,用 size 来表示当前堆的大小

1.插入一个数:当我们想往堆中插入一个数的时候,就是在末尾插入一个数,然后 up 往上调整一遍(在整个堆的最后一个位置插入一个新的数 x,然后把这个数不断往上移)

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

2.求这个集合中的最小值:求当前堆中的最小值,就是第一个数

heap[1];

3.删除最小值:拿最后一个数覆盖掉第一个数,然后把最后一个数干掉,再 down 一遍

①需要把第 1 个元素删除,用整个堆的最后一个元素来覆盖堆顶元素,堆顶就没有了 ②覆盖后让 size- - 把最后一个元素删掉就可以了:一维数组删除头节点是非常困难的,但是想删除尾节点是非常方便的,想删除最后一个数,只要让size - - 即可 ③然后把堆顶 down 一遍,让一号点往下走,维护根节点

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

5.删除任意一个元素:拿最后一个数覆盖掉要删除的数,然后把最后一个数干掉,再 down 一遍、up 一遍

和删除根节点类似,假设想删除第 k 个点的话

heap[ k ] = heap[ size ];size - -;down(k);up(k);(需要分情况来判断,如果 heap[ k ] 的值变大了,就应该 down 一遍(如果变大应该往下走)变小就应该 up 一遍(如果变小应该往上走)

为了简化代码可以不用判断,heap[ k ] 的值最多只有 3 种情况, 要么是不变就不用变,要么是变大了要往下走,要么是变小了要往上走,每次这 3 种情况只会有其中一种情况成立,直接 up 一遍、down 一遍就可以了,只会执行其中一个:只有变大才会往下走,只有变小才会往上走)

6.修改任意一个元素:把某一个数修改完之后,再 down 一遍、up 一遍

同理,假设想把第 k 个元素修改为 x 的话

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

注意:下标都是从 1 开始的,假设下标从 0 开始的话,0 的左儿子是 2 × 0 = 0,就会发生冲突,下标从 1 开始,左儿子是 2,右儿子是 3

堆排序

down 操作:是一个递归的过程,从根节点开始,如果当前这个点比某一个子节点大的话,就把那个子节点换上来,换完之后,再递归处理

题目要求输入一个长度为 n 的整数数列,从小到大输出前 m 小的数,其实就是一个堆排序

堆排序的基本思路:

先把整个数组建成堆,每次把堆顶输出出来,第一次堆顶就是最小的数,第二次就是第二小的数,第三次就是第三小的数

第 1 步:需要建堆,每次需要先把堆顶输出,需要用到操作 2

第 2 步:把堆顶删掉,需要用到操作 3,并且只要用到 down 操作

up 操作和 down 操作的时间复杂度和树的高度成正比,时间复杂度为 O(logn)

插入、删除的时间复杂度为 O(logn),求最小值直接返回 h[ 1 ] 即可时间复杂度为 O(1)

如何把一个数组建成堆?

可以采用插入的方式一个个往里面插,每一次插入操作 logn,一共 n 步,时间复杂度为 O(nlogn)

时间复杂度为 O(n) 的建堆方式

直接从 n / 2 down 到 n

首先是一棵完全二叉树,从 2 / n 开始 down 操作,2 / n 的时候高度是 1,也是倒数第 2 层的最后一个点,倒数第 2 层是 n / 4 个元素,需要 down 一层,倒数第 3 层有 8 / n 个元素,需要往下 down 两层. . .以此类推,整理式子得到:S < 1,因此整个的时间复杂度为 O( n )

最后一层只有一个点是不需要 down 的

AcWing 838. 堆排序_Xin_Hack的博客-CSDN博客

AcWing 838 堆排序_昂昂累世士的博客-CSDN博客

【C++】数组模拟堆操作 AcWing 838. 堆排序 (算法基础课笔记)_Cpt丶的博客-CSDN博客

#include <iostream>
#include <algorithm>

const int N = 100010;

int n,m;
//h表示堆 size存储堆里面有多少个元素
int h[N],size;

//down操作其实就是看一下当前点是不是这三个点里面的最小值
void down(int u) 
{
    //用t来表示3个点里面的最小值的编号
    int t = u;
    //判断有没有左儿子 左儿子存在并且左儿子的值小于h[t] 就让t等于左儿子
    if(u * 2 <= size && h[u * 2] < h[t]) t = u * 2;
    //判断有没有右儿子 右儿子存在并且右儿子的值小于h[t] 就让t等于右儿子
    if(u * 2 + 1 <= size && h[u * 2 + 1] < h[t]) t = u * 2 + 1;  
    //最后t存储的就是三个节点里面最小的编号
    //如果u != t说明根节点不是最小值
    if(u != t)
    {
        //根节点需要和最小值交换
        swap(h[u],h[t]);
        //递归处理
        down(t);
    }
}

//每次往上走
void up(int u)
{
    //只要有父节点 并且父节点比当前节点大的话就不平衡了 就要把当前节点换上去
    while(u / 2 /* > 0 */ && h[u / 2] > h[u])
    {
        //交换
        swap(h[ u / 2],h[u]);
        u /= 2;
    }
}

int main()
{
    //进行m次操作
    scanf("%d%d",&n,&m);
    for(int i  = 1;i <= n;i++ ) scanf("%d",&h[i]);
    size = n;
    for(int i = n / 2;i;i-- ) down(i);
    //每次操作把当前的堆顶元素输出
    while(m--)
    {
        printf("%d ",h[1]);
        //输出堆顶后要把堆顶删除:把最后一个元素赋到第一个元素上去
        h[1] = h[size];
        size --;
        down(1);
    }
    return 0;
}

上面的递归操作,可以改成循环,但是没有必要,由于整个堆是一棵完全二叉树,堆的高度最多是 logn 层,假设 n = 100w,也就 20 层,所以没有必要改成循环

模拟堆

 

在做插入和删除的时候,需要快速找到第 k 个插入的数在哪(然后才能对它做修改或者删除),需要开两个额外的数组存储

ph[ k ] 存储第 k 个插入的点在堆里面的下标,hp[ k ] 存储堆里面的某一个点是第几个插入的点

带映射的堆

ph[ ] 和 hp[ ] 存储第 k 个插入的点和堆里面每一个元素的映射

在进行 down 操作的时候,需要把两个点交换,交换两个点是把这两个点的值交换,未交换之前,有一个 ph[ i ] 指向第 1 个点,有一个 ph[ j ] 指向第 2 个点,交换之后,ph[ i ] 应该指向下面这个点,ph[ j ] 应该指向上面这个点

需要知道这个点是由哪个 ph[ i ] 转移过来的,由哪个 ph[ i ] 指向我的,因此需要存储一个 hp[ ]

假设下图点的下标是 k,ph[ j ] = k,第 j 个插入的点在堆里面的下标是 k,hp[ k ] = j,表示堆里面下标是 k 的点对应 ph[ ] 中是第几个插入的数,两者互为反函数,既要从第几个插入的点去找到堆里面的元素,又要从堆里面的元素找回来,两者要 一 一 对应,因此需要用两个数组来存储

 

 

在堆里面想交换两个元素,由于两个值交换了,ph[ i ]、ph[ j ] 也应该指向新的位置,hp 指针出现交错的情况,hp[ i ]、hp[ j ] 也应该指向新的位置

 

带映射的堆不常用,一般用到的堆不需要存储映射,只需要实现简单的 up 和 down 操作,只有迪杰斯特拉算法需要用到这个堆

想删除或者修改堆当中任意一个元素,需要存储一个映射关系

p:第 k 个插入的数的下标

h:堆

ph:从下标映射到堆里面,hp:从堆里面映射到下标

#include <iostream>
#include <algorithm>
#include <string.h>

const int N = 100010;

//堆 size存储堆里面有多少个元素
int h[N],size,ph[N],hp[N];

//当我们交换两个点的时候 不能仅仅交换两个值
void heap_swap(int a,int b)
{
    //交换ph指针 ph[i]=k hp[k]=i 从i能找到这个点,也能从这个点找回i
    //hp[k]就是从这个点找回去找到ph[i]
    swap(ph[hp[a]],ph[hp[b]]);
    //交换hp指针
    swap(hp[a],hp[b]);
    //需要交换两个数
    swap(h[a],h[b]);
}

void down(int u)
{
    //用t来表示3个点里面的最小值
    int t = u;
    //看有没有左儿子 左儿子存在并且左儿子小于h[t] 就让t等于左儿子
    if(u * 2 <= size && h[u * 2] < h[t]) t = u * 2;
    //看有没有右儿子 右儿子存在并且右儿子小于h[t]
    if(u * 2 + 1 <= size && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    //t存储的就是三个节点中的最小编号
    //说明根节点存储的不是最小值
    if(u != t)
    {
        //需要交换
        heap_swap(u,t);
        //递归处理
        down(t);
    }
}

void up(int u)
{
     while(u / 2 /* > 0 */ && h[u / 2] > h[u])
    {
        //交换
        heap_swap(u / 2,u);
        u /= 2;
    }
}

int main()
{    
    //m存储当前是第几个插入的数
    int n,m = 0;
    scanf("%d",&n);
    while(n-- )
    {
        //读入每一个操作
        char op[10];
        int k,x;
        scanf("%s",op);
        //如果当前的操作是插入
        if(!strcmp(op,"I"));
        {
            //把要插入的数先读进来
            scanf("%d",&x);
            //堆里面多了一个元素size++
            size ++;
            //操作的都是第几个插入的数,需要用变量存储下来
            m ++;
            //当前是第m个插入的数
            //第m个元素要放在最后一个元素的位置:因此第m个插入的数一开始在堆里面的size位置
            //堆里面size位置上的数一开始对应的是m
            ph[m] = size,hp[size] = m;
            //当前位置插入的值为x
            h[size] = x;
            //向上调整
            up(size);
        }
        //输出当前集合中的最小值
        else if(!strump(op,"PM")) printf("%d\n",h[1]);
        //删除当前集合中的最小值
        else if(!strump(op,"DM"))
        {
            //把最后一个元素换到第一个元素的位置上
            heap_swap(1,size);
            size --;
            //向下调整
            down(1);
        }
        //删除第k个插入的数
        else if(!strump(op,"D"))
        {
            //读入要删除的第k个插入的数
            scanf("%d",&k);
            //让k找到它在堆里面的位置
            k = ph[k];
            //把最后一个元素和k做交换
            heap_swap(k,size);
            size --;
            //两个函数最多只会执行其中一个
            down(k),up(k);
        }
        //将第k个插入的数修改
        else
        {
            scanf("%d%d",&k,&x);
            //找到第k个插入的数
            k = ph[k];
            //把它直接修改为x
            h[k] = x;
            down(k),up(k);
        }
    }
    return 0;
}
  • 6
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
系统根据B/S,即所谓的电脑浏览器/网络服务器方式,运用Java技术性,挑选MySQL作为后台系统。系统主要包含对客服聊天管理、字典表管理、公告信息管理、金融工具管理、金融工具收藏管理、金融工具银行卡管理、借款管理、理财产品管理、理财产品收藏管理、理财产品银行卡管理、理财银行卡信息管理、银行卡管理、存款管理、银行卡记录管理、取款管理、转账管理、用户管理、员工管理等功能模块。 文中重点介绍了银行管理的专业技术发展背景和发展状况,随后遵照软件传统式研发流程,最先挑选适用思维和语言软件开发平台,依据需求分析报告模块和设计数据库结构,再根据系统功能模块的设计制作系统功能模块图、流程表和E-R图。随后设计架构以及编写代码,并实现系统能模块。最终基本完成系统检测和功能测试。结果显示,该系统能够实现所需要的作用,工作状态没有明显缺陷。 系统登录功能是程序必不可少的功能,在登录页面必填的数据有两项,一项就是账号,另一项数据就是密码,当管理员正确填写并提交这二者数据之后,管理员就可以进入系统后台功能操作区。进入银行卡列表,管理员可以进行查看列表、模糊搜索以及相关维护等操作。用户进入系统可以查看公告和模糊搜索公告信息、也可以进行公告维护操作。理财产品管理页面,管理员可以进行查看列表、模糊搜索以及相关维护等操作。产品类型管理页面,此页面提供给管理员的功能有:新增产品类型,修改产品类型,删除产品类型。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

qiuqiuyaq

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值