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