Day 8 - 并查集、堆、set 与 map
1|0并查集
1|1引入
并查集是一种用于管理元素所属集合的数据结构,实现为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素。
顾名思义,并查集支持两种操作:
- 合并(
):合并两个元素所属集合(合并对应的树) - 查询(
):查询某个元素所属集合(查询对应的树的根节点),这可以用于判断两个元素是否属于同一集合
并查集在经过修改后可以支持单个元素的删除、移动;使用动态开点线段树还可以实现可持久化并查集。
并查集无法以较低复杂度实现集合的分离。
1|2初始化
初始时,每个元素都位于一个单独的集合,表示为一棵只有根节点的树。方便起见,我们将根节点的父亲设为自己。
实现:
1|3查询
我们需要沿着树向上移动,直至找到根节点。
实现:
路径压缩
查询过程中经过的每个元素都属于该集合,我们可以将其直接连到根节点以加快后续查询。
实现:
1|4合并
要合并两棵树,我们只需要将一棵树的根节点连到另一棵树的根节点。
实现:
启发式合并
合并时,选择哪棵树的根节点作为新树的根节点会影响未来操作的复杂度。我们可以将节点较少或深度较小的树连到另一棵,以免发生退化。
具体复杂度讨论:
由于需要我们支持的只有集合的合并、查询操作,当我们需要将两个集合合二为一时,无论将哪一个集合连接到另一个集合的下面,都能得到正确的结果。但不同的连接方法存在时间复杂度的差异。具体来说,如果我们将一棵点数与深度都较小的集合树连接到一棵更大的集合树下,显然相比于另一种连接方案,接下来执行查找操作的用时更小(也会带来更优的最坏时间复杂度)。
当然,我们不总能遇到恰好如上所述的集合——点数与深度都更小。鉴于点数与深度这两个特征都很容易维护,我们常常从中择一,作为估价函数。而无论选择哪一个,时间复杂度都为
在算法竞赛的实际代码中,即便不使用启发式合并,代码也往往能够在规定时间内完成任务。在 Tarjan 的论文
如果只使用启发式合并,而不使用路径压缩,时间复杂度为
按节点数合并的参考实现:
实现:
1|5删除
要删除一个叶子节点,我们可以将其父亲设为自己。为了保证要删除的元素都是叶子,我们可以预先为每个节点制作副本,并将其副本作为父亲。
实现:
1|6移动
与删除类似,通过以副本作为父亲,保证要移动的元素都是叶子。
实现:
1|7复杂度
时间复杂度
同时使用路径压缩和启发式合并之后,并查集的每个操作平均时间仅为
而反
空间复杂度
显然为
1|8带权并查集
我们还可以在并查集的边上定义某种权值、以及这种权值在路径压缩时产生的运算,从而解决更多的问题。比如对于经典的「
1|9例题
实现类似并查集的数据结构,支持以下操作:
- 合并两个元素所属集合
- 移动单个元素
- 查询某个元素所属集合的大小及元素和
1|10习题
1|11其他应用
最小生成树算法中的
1|12参考资料与拓展阅读
- 知乎回答:是否在并查集中真的有二分路径压缩优化?
- Gabow, H. N., & Tarjan, R. E. (1985). A Linear-Time Algorithm for a Special Case of Disjoint Set Union. JOURNAL OF COMPUTER AND SYSTEM SCIENCES, 30, 209-221.PDF
2|0并查集时间复杂度
本部分内容转载并修改自 时间复杂度 - 势能分析浅谈,已取得原作者授权同意。
2|1定义
阿克曼函数
这里,先给出
定义
即阿克曼函数。
这里,
再定义
基础定义
每个节点都有一个 rank。这里的 rank 不是节点个数,而是深度。节点的初始 rank 为 0,在合并的时候,如果两个节点的 rank 不同,则将 rank 小的节点合并到 rank 大的节点上,并且不更新大节点的 rank 值。否则,随机将某个节点合并到另外一个节点上,将根节点的 rank 值 +1。这里根节点的 rank 给出了该树的高度。记 x 的 rank 为
为了定义势函数,需要预先定义一个辅助函数
上面那些定义可能让你有点头晕。再理一下,对于一个
对于这两个函数,
考虑
定义势能函数
然后就是通过操作引起的势能变化来证明摊还时间复杂度为
可以发现,势能总是个非负数。另,在开始的时候,并查集的势能为
2|2证明
union(x,y) 操作
其花费的时间为
这里,我们假设
设操作前
和 并未增加。显然有 。 增加了, 并未增加。这里 至少增加一,即 ,势能函数减少了,并且至少减少 1。 增加了, 可能减少。但是由于 , 最多减少 ,而 至少增加 。由定义 ,可得 。- 其他情况。由于
不变, 不减,所以不存在。
所以,势能增加的节点仅可能是
因此,唯一势能可能增加的点就是
find(a) 操作
如果查找路径包含
首先证明没有节点的势能增加。很显然,我们在上面证明过所有非根节点的势能不增,而根节点的
接下来证明至少有
回忆一下非根节点势能的定义,
所以,如果
注意,我们可能会用
当你看到这的时候,可能会有一种「这啥玩意」的感觉。这意味着你可能需要多看几遍,或者跳过一些内容以后再看。
这里,我们需要一个外接的
我们再强调一遍
如果我们将不等式组合起来,神奇的事情就发生了。我们发现,
显然,有
所以,
2|3为何并查集会被卡
这个问题也就是问,如果我们不按秩合并,会有哪些性质被破坏,导致并查集的时间复杂度不能保证为
如果我们在合并的时候,
显然,如果这样子的话,我们破坏的就是
存在一个能使路径压缩并查集时间复杂度降至
二项树(实际上和一般的二项树不太一样),其中 j 是常数,
边界条件,
令
变换一下,去掉所有的取整符号,就可以得出,势能增加量
2|4关于启发式合并
由于按秩合并比启发式合并难写,所以很多 dalao 会选择使用启发式合并来写并查集。具体来说,则是对每个根都维护一个
所以,启发式合并会不会被卡?
首先,可以从秩参与证明的性质来说明。如果
- 每次合并,最多有一个节点的秩上升,而且最多上升 1。
- 总有
。 - 节点的秩不减。
关于第二条和第三条,
所以,可以考虑使用
关于第一条性质,由于节点的
所以说,如果不想写按秩合并,就写启发式合并好了,时间复杂度仍旧是
3|0堆
堆是一棵树,其每个节点都有一个键值,且每个节点的键值都大于等于/小于等于其父亲的键值。
每个节点的键值都大于等于其父亲键值的堆叫做小根堆,否则叫做大根堆。STL 中的 priority_queue
其实就是一个大根堆。
(小根)堆主要支持的操作有:插入一个数、查询最小值、删除最小值、合并两个堆、减小一个元素的值。
一些功能强大的堆(可并堆)还能(高效地)支持
一些功能更强大的堆还支持可持久化,也就是对任意历史版本进行查询或者操作,产生新的版本。
3|1堆的分类
操作 \ 数据结构 |
配对堆 | 二叉堆 | 左偏树 | 二项堆 | 斐波那契堆 |
---|---|---|---|---|---|
插入(insert) | |||||
查询最小值(find-min) | |||||
删除最小值(delete-min) | |||||
合并 (merge) | |||||
减小一个元素的值 (decrease-key) | |||||
是否支持可持久化 |
习惯上,不加限定提到「堆」时往往都指二叉堆。
4|0二叉堆
4|1结构
从二叉堆的结构说起,它是一棵二叉树,并且是完全二叉树,每个结点中存有一个元素(或者说,有个权值)。
堆性质:父亲的权值不小于儿子的权值(大根堆)。同样的,我们可以定义小根堆。本文以大根堆为例。
由堆性质,树根存的是最大值(
4|2过程
插入操作
插入操作是指向二叉堆中插入一个元素,要保证插入后也是一棵完全二叉树。
最简单的方法就是,最下一层最右边的叶子之后插入。
如果最下一层已满,就新增一层。
插入之后可能会不满足堆性质?
向上调整:如果这个结点的权值大于它父亲的权值,就交换,重复此过程直到不满足或者到根。
可以证明,插入之后向上调整后,没有其他结点会不满足堆性质。
向上调整的时间复杂度是
删除操作
删除操作指删除堆中最大的元素,即删除根结点。
但是如果直接删除,则变成了两个堆,难以处理。
所以不妨考虑插入操作的逆过程,设法将根结点移到最后一个结点,然后直接删掉。
然而实际上不好做,我们通常采用的方法是,把根结点和最后一个结点直接交换。
于是直接删掉(在最后一个结点处的)根结点,但是新的根结点可能不满足堆性质……
向下调整:在该结点的儿子中,找一个最大的,与该结点交换,重复此过程直到底层。
可以证明,删除并向下调整后,没有其他结点不满足堆性质。
时间复杂度
增加某个点的权值
很显然,直接修改后,向上调整一次即可,时间复杂度为
4|3实现
我们发现,上面介绍的几种操作主要依赖于两个核心:向上调整和向下调整。
考虑使用一个序列
参考代码:
建堆
考虑这么一个问题,从一个空的堆开始,插入
直接一个一个插入需要
方法一:使用 decreasekey(即,向上调整)
从根开始,按
为啥这么做:对于第
总复杂度:
(在「基于比较的排序」中证明过)
方法二:使用向下调整
这时换一种思路,从叶子开始,逐个向下调整
换一种理解方法,每次「合并」两个已经调整好的堆,这说明了正确性。
注意到向下调整的复杂度,为
证明:
之所以能
要是像排序那样的强条件就难说了。
4|4应用
对顶堆
SPOJ RMID2 - Running Median Again。
维护一个序列,支持两种操作:
- 向序列中插入一个元素
- 输出并删除当前序列的中位数(若序列长度为偶数,则输出较小的中位数)
这个问题可以被进一步抽象成:动态维护一个序列上第
对于此类问题,我们可以使用 对顶堆 这一技巧予以解决(可以避免写权值线段树或 BST 带来的繁琐)。
对顶堆由一个大根堆与一个小根堆组成,小根堆维护大值即前
这两个堆构成的数据结构支持以下操作:
- 维护:当小根堆的大小小于
时,不断将大根堆堆顶元素取出并插入小根堆,直到小根堆的大小等于 ;当小根堆的大小大于 时,不断将小根堆堆顶元素取出并插入大根堆,直到小根堆的大小等于 ; - 插入元素:若插入的元素大于等于小根堆堆顶元素,则将其插入小根堆,否则将其插入大根堆,然后维护对顶堆;
- 查询第
大元素:小根堆堆顶元素即为所求; - 删除第
大元素:删除小根堆堆顶元素,然后维护对顶堆; 值 :根据新的 值直接维护对顶堆。
显然,查询第
参考代码:
习题
5|0匹配堆
5|1引入
配对堆是一个支持插入,查询/删除最小值,合并,修改元素等操作的数据结构,是一种可并堆。有速度快和结构简单的优势,但由于其为基于势能分析的均摊复杂度,无法可持久化。
5|2定义
配对堆是一棵满足堆性质的带权多叉树(如下图),即每个节点的权值都小于或等于他的所有儿子(以小根堆为例,下同)。
通常我们使用儿子 - 兄弟表示法储存一个配对堆(如下图),一个节点的所有儿子节点形成一个单向链表。每个节点储存第一个儿子的指针,即链表的头节点;和他的右兄弟的指针。
这种方式便于实现配对堆,也将方便复杂度分析。
从定义可以发现,和其他常见的堆结构相比,配对堆不维护任何额外的树大小,深度,排名等信息(二叉堆也不维护额外信息,但它是通过维持一个严格的完全二叉树结构来保证操作的复杂度),且任何一个满足堆性质的树都是一个合法的配对堆,这样简单又高度灵活的数据结构奠定了配对堆在实践中优秀效率的基础;作为对比,斐波那契堆糟糕的常数就是因为它需要维护很多额外的信息。
配对堆通过一套精心设计的操作顺序来保证它的总复杂度,原论文[1]将其称为「一种自调整的堆(
5|3过程
查询最小值
从配对堆的定义可看出,配对堆的根节点的权值一定最小,直接返回根节点即可。
合并
合并两个配对堆的操作很简单,首先令两个根节点较小的一个为新的根节点,然后将较大的根节点作为它的儿子插入进去。(见下图)
需要注意的是,一个节点的儿子链表是按插入时间排序的,即最右边的节点最早成为父节点的儿子,最左边的节点最近成为父节点的儿子。
实现:
插入
合并都有了,插入就直接把新元素视为一个新的配对堆和原堆合并就行了。
删除最小值
首先要提及的一点是,上文的几个操作都十分偷懒,完全没有对数据结构进行维护,所以我们需要小心设计删除最小值的操作,来保证总复杂度不出问题。
根节点即为最小值,所以要删除的是根节点。考虑拿掉根节点之后会发生什么:根节点原来的所有儿子构成了一片森林;而配对堆应当是一棵树,所以我们需要通过某种顺序把这些儿子全部合并起来。
一个很自然的想法是使用 meld
函数把儿子们从左到右挨个并在一起,这样做的话正确性是显然的,但是会导致单次操作复杂度退化到
为了保证总的均摊复杂度,需要使用一个「两步走」的合并方法:
- 把儿子们两两配成一对,用
meld
操作把被配成同一对的两个儿子合并到一起(见下图 1), - 将新产生的堆 从右往左(即老的儿子到新的儿子的方向)挨个合并在一起(见下图 2)。
先实现一个辅助函数 merges
,作用是合并一个节点的所有兄弟。
实现:
最后一句话是该函数的核心,这句话分三部分:
meld(x,y)
「配对」了 和 。merges(c)
递归合并 和他的兄弟们。- 将上面
个操作产生的 个新树合并。
需要注意到的是,上文提到了第二步时的合并方向是有要求的(从右往左合并),该递归函数的实现已保证了这个顺序,如果读者需要自行实现迭代版本的话请务必注意保证该顺序,否则复杂度将失去保证。
有了 merges
函数,delete-min
操作就显然了。
实现:
减小一个元素的值
要实现这个操作,需要给节点添加一个「父」指针,当节点有左兄弟时,其指向左兄弟而非实际的父节点;否则,指向其父节点。
首先节点的定义修改为:
实现:
meld
操作修改为:
实现:
merges
操作修改为:
实现:
现在我们来考虑如何实现 decrease-key
操作。
首先我们发现,当我们减少节点 x
的权值之后,以 x
为根的子树仍然满足配对堆性质,但 x
的父亲和 x
之间可能不再满足堆性质。
因此我们把整棵以 x
为根的子树剖出来,现在两棵树都符合配对堆性质了,然后把他们合并起来,就完成了全部操作。
实现:
5|4复杂度分析
配对堆结构与实现简单,但时间复杂度分析并不容易。
原论文meld
和 delete-min
操作均为均摊
遗憾的是,后续发现,不维护额外信息的配对堆,在特定的操作序列下,decrease-key
操作的均摊复杂度下界至少为
目前对复杂度上界比较好的估计有,Iacono 的 meld
,decrease-key
meld
和 decrease-key
5|5参考文献
6|0set
set
是关联容器,含有键值类型对象的已排序集,搜索、移除和插入拥有对数复杂度。set
内部通常采用红黑树实现。平衡二叉树的特性使得 set
非常适合处理需要同时兼顾查找、插入与删除的情况。
和数学中的集合相似,set
中不会出现值相同的元素。如果需要有相同元素的集合,需要使用 multiset
。multiset
的使用方法与 set
的使用方法基本相同。
6|1插入与删除操作
insert(x)
当容器中没有等价元素的时候,将元素 x 插入到set
中。erase(x)
删除值为 x 的 所有 元素,返回删除元素的个数。erase(pos)
删除迭代器为 pos 的元素,要求迭代器必须合法。erase(first,last)
删除迭代器在 范围内的所有元素。clear()
清空set
。
pair<iterator, bool>
,其中 set
中的元素具有唯一性质,所以如果在 set
中已有等值元素,则插入会失败,返回 map
中的
6|2迭代器
set
提供了以下几种迭代器:
begin()/cbegin()
返回指向首元素的迭代器,其中*begin = front
。end()/cend()
返回指向数组尾端占位符的迭代器,注意是没有元素的。rbegin()/crbegin()
返回指向逆向数组的首元素的逆向迭代器,可以理解为正向容器的末元素。rend()/crend()
返回指向逆向数组末元素后一位置的迭代器,对应容器首的前一个位置,没有元素。
以上列出的迭代器中,含有字符 c
的为只读迭代器,你不能通过只读迭代器去修改 set
中的元素的值。如果一个 set
本身就是只读的,那么它的一般迭代器和只读迭代器完全等价。只读迭代器自
6|3查找操作
count(x)
返回set
内键为 的元素数量。find(x)
在set
内存在键为 的元素时会返回该元素的迭代器,否则返回end()
。lower_bound(x)
返回指向首个不小于给定键的元素的迭代器。如果不存在这样的元素,返回end()
。upper_bound(x)
返回指向首个大于给定键的元素的迭代器。如果不存在这样的元素,返回end()
。empty()
返回容器是否为空。size()
返回容器内元素个数。
lower_bound
和 upper_bound
的时间复杂度:
set
自带的 lower_bound
和 upper_bound
的时间复杂度为
但使用 algorithm
库中的 lower_bound
和 upper_bound
函数对 set
中的元素进行查询,时间复杂度为
nth_element
的时间复杂度 :
set
没有提供自带的 nth_element
。使用 algorithm
库中的 nth_element
查找第
如果需要实现平衡二叉树所具备的
6|4使用样例
set 在贪心中的使用
在贪心算法中经常会需要出现类似 找出并删除最小的大于等于某个值的元素。这种操作能轻松地通过 set
来完成。
7|0map
map
是有序键值对容器,它的元素的键是唯一的。搜索、移除和插入操作拥有对数复杂度。map
通常实现为红黑树。
设想如下场景:现在需要存储一些键值对,例如存储学生姓名对应的分数:Tom 0
,Bob 100
,Alan 100
。但是由于数组下标只能为非负整数,所以无法用姓名作为下标来存储,这个时候最简单的办法就是使用 map
。
map
重载了 operator[]
,可以用任意定义了 operator <
的类型作为下标(在 map
中叫做 key
,也就是索引):
其中,Key
是键的类型,T
是值的类型,下面是使用 map
的实例:
map
中不会存在键相同的元素,multimap
中允许多个元素拥有同一键。multimap
的使用方法与 map
的使用方法基本相同。
正是因为 multimap
允许多个元素拥有同一键的特点,multimap
并没有提供给出键访问其对应值的方法。
插入与删除操作
- 可以直接通过下标访问来进行查询或插入操作。例如
mp["Alan"]=100
。 - 通过向
map
中插入一个类型为pair<Key, T>
的值可以达到插入元素的目的,例如mp.insert(pair<string,int>("Alan",100));
; erase(key)
函数会删除键为key
的 所有 元素。返回值为删除元素的数量。erase(pos)
: 删除迭代器为 pos 的元素,要求迭代器必须合法。erase(first,last)
: 删除迭代器在 范围内的所有元素。clear()
函数会清空整个容器。
下标访问中的注意事项:
在利用下标访问 map
中的某个元素时,如果 map
中不存在相应键的元素,会自动在 map
中插入一个新元素,并将其值设置为默认值(对于整数,值为零;对于有默认构造函数的类型,会调用默认构造函数进行初始化)。
当下标访问操作过于频繁时,容器中会出现大量无意义元素,影响 map
的效率。因此一般情况下推荐使用 find()
函数来寻找特定键的元素。
7|1查询操作
count(x)
: 返回容器内键为 x 的元素数量。复杂度为 (关于容器大小对数复杂度,加上匹配个数)。find(x)
: 若容器内存在键为 x 的元素,会返回该元素的迭代器;否则返回end()
。lower_bound(x)
: 返回指向首个不小于给定键的元素的迭代器。upper_bound(x)
: 返回指向首个大于给定键的元素的迭代器。若容器内所有元素均小于或等于给定键,返回end()
。empty()
: 返回容器是否为空。size()
: 返回容器内元素个数。
7|2使用样例
map 用于存储复杂状态
在搜索中,我们有时需要存储一些较为复杂的状态(如坐标,无法离散化的数值,字符串等)以及与之有关的答案(如到达此状态的最小步数)。map
可以用来实现此功能。其中的键是状态,而值是与之相关的答案。下面的示例展示了如何使用 map
存储以 string
表示的状态。
7|3遍历容器
可以利用迭代器来遍历关联式容器的所有元素。
需要注意的是,对 map
的迭代器解引用后,得到的是类型为 pair<Key, T>
的键值对。
在
对于任意关联式容器,使用迭代器遍历容器的时间复杂度均为
7|4自定义比较方式
set
在默认情况下的比较函数为 <
(如果是非内置类型需要重载 <
运算符)。然而在某些特殊情况下,我们希望能自定义 set
内部的比较方式。
这时候可以通过传入自定义比较器来解决问题。
具体来说,我们需要定义一个类,并在这个类中重载 ()
运算符。
例如,我们想要维护一个存储整数,且较大值靠前的 set
,可以这样实现:
对于其他关联式容器,可以用类似的方式实现自定义比较,这里不再赘述。
__EOF__

本文链接:https://www.cnblogs.com/So-noSlack/p/18302382.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
本文来自博客园,作者:So_noSlack,转载请注明原文链接:https://www.cnblogs.com/So-noSlack/p/18302382
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
2023-07-15 第六节 搜索专题 - 2