Jerry's Blog

Back

#

堆用来解决一些需要快速排序,并且每次只关心排序后最值的问题.

C++ STL 中的优先队列就是用堆实现的.

堆是特殊的二叉树,有一个最重要的性质:对于堆中的任何一个节点,都保证其子节点的值严格大于父节点.

例如下面一张图就符合堆的性质

graph TD
    A((3)) --> B((5))
    A --> C((6))
    B --> D((8))
    B --> E((10))
    C --> F((11))
    C ~~~ Empty(( ))
    style Empty fill:#fff,stroke-width:0px

堆的操作#

节点添加#

设需要在上图中添加一个元素 4,我们可以这么做:

首先,我们先将要添加的节点放到最下一层最右边的叶子之后.如果最下一层已满,就新增一层.

graph TD
    A((3)) --> B((5))
    A --> C((6))
    B --> D((8))
    B --> E((10))
    C --> F((11))
    C --> G((4))

而后,尝试让新的节点和其父节点交换位置.在这个例子中,显然,4 是比 6 要小的,为了保证堆的性质,我们要让 4 和 6 换个位置.

graph TD
    A((3)) --> B((5))
    A --> C((4))
    B --> D((8))
    B --> E((10))
    C --> F((11))
    C --> G((6))

显然,3 是比 4 要小的,已经满足了堆的性质,这一次添加操作就此结束.

堆的删除#

堆支持删除最顶上的一个节点.

graph TD
    A((3)) --> B((5))
    A --> C((4))
    B --> D((8))
    B --> E((10))
    C --> F((11))
    C --> G((6))

为了对上面一张图的顶点(即值为 3 的点)进行删除,我们可以这样做:

首先,把这个点和最后一个点交换位置

graph TD
    A((6)) --> B((5))
    A --> C((4))
    B --> D((8))
    B --> E((10))
    C --> F((11))
    C --> G((3))

再把最后一个点删掉

graph TD
    A((6)) --> B((5))
    A --> C((4))
    B --> D((8))
    B --> E((10))
    C --> F((11))
    C ~~~ Empty(( ))
    style Empty fill:#fff,stroke-width:0px

然后,由于这个图不满足堆的定义,所以我们从他的子节点中选择一个最小的,和根节点交换,作为新的根节点.

graph TD
    A((4)) --> B((5))
    A --> C((6))
    B --> D((8))
    B --> E((10))
    C --> F((11))
    C ~~~ Empty(( ))
    style Empty fill:#fff,stroke-width:0px

之后一直交换,直到其所有子节点都大于父节点,或没有子节点为止.

查询最小值#

这个最简单,此时堆顶的元素最小,没有比它更小的了.

复杂度分析#

节点添加操作平均是 O(logn)O(\log n) 的,但最坏 O(n)O(n)

查询最小值 O(1)O(1)

删除操作平均是 O(logn)O(\log n) 的,但最坏 O(n)O(n)

题目:P3378 【模板】堆

题目描述#

给定一个数列,初始为空,请支持下面三种操作:

  1. 给定一个整数 xx,请将 xx 加入到数列中.
  2. 输出数列中最小的数.
  3. 删除数列中最小的数(如果有多个数最小,只删除 11 个).

输入格式#

第一行是一个整数,表示操作的次数 nn. 接下来 nn 行,每行表示一次操作.每行首先有一个整数 opop 表示操作类型.

  • op=1op = 1,则后面有一个整数 xx,表示要将 xx 加入数列.
  • op=2op = 2,则表示要求输出数列中的最小数.
  • op=3op = 3,则表示删除数列中的最小数.如果有多个数最小,只删除 11 个.

输出格式#

对于每个操作 22,输出一行一个整数表示答案.

数据范围#

对于 100%100\% 的数据,保证 1n1061 \leq n \leq 10^61x<2311 \leq x \lt 2^{31}op{1,2,3}op \in \{1, 2, 3\}

标程#

二叉搜索树#

题目:P3369【模板】普通平衡树

题目描述#

您需要动态地维护一个可重集合 MM,并且提供以下操作:

  1. MM 中插入一个数 xx
  2. MM 中删除一个数 xx(若有多个相同的数,应只删除一个).
  3. 查询 MM 中有多少个数比 xx 小,并且将得到的答案加一.
  4. 查询如果将 MM 从小到大排列后,排名位于第 xx 位的数.
  5. 查询 MMxx 的前驱(前驱定义为小于 xx,且最大的数).
  6. 查询 MMxx 的后继(后继定义为大于 xx,且最小的数).

对于操作 3,5,63,5,6不保证当前可重集中存在数 xx

对于操作 5,65,6,保证答案一定存在.

输入格式#

第一行为 nn,表示操作的个数,下面 nn 行每行有两个数 opt\text{opt}xxopt\text{opt} 表示操作的序号(1opt61 \leq \text{opt} \leq 6).

输出格式#

对于操作 3,4,5,63,4,5,6 每行输出一个数,表示对应答案.

数据范围#

对于 100%100\% 的数据,1n1051\le n \le 10^5x107|x| \le 10^7

二叉搜索树是一种特殊的树,其特点为:对于一个节点,其左子树上的节点总是小于父节点,而其右子树上的节点总是大于父节点.

例如下面一张图就符合二叉搜索树的性质

graph TD
    A((10)) --> B((8))
    A --> C((15))
    B --> D((5))
    B --> E((9))
    C --> F((11))
    C ~~~ Empty(( ))
    style Empty fill:#fff,stroke-width:0px

对于每一个节点,我们需要记录一些信息:

  • v:该节点的值.
  • num:为了让二叉搜索树能够存储重复的数据,我们将数值相同的节点合并.num 表示树中共有 num 个数值为 v 的值,即该节点是由 num 个节点合并而来的.
  • lson:该节点的左子节点.
  • rson:该节点的右子节点.
  • size:以本节点为根的子树中一共有多少个数(此处不是节点,是因为每一个节点可能储存多个相同的数),包括根节点.

定义一个类用来存放操作函数:

class Tree {
public:
	// 当前根节点
	int ROOT = 0;
	// 当前树中节点数量
	int tot = 0;
	queue<int> free_node;
};
cpp

插入#

插入很简单,只需要从根节点向下遍历一遍,找到最适合的位置.

对于一个节点 kk,要在以该节点为根的子树中插入一个元素 xx,有两种情况

  • 若节点 kk 是空的,则将 xx 放在 kk 的位置.
  • 若节点 kk 非空,则
    • x=kx = k,将节点 kknum 值增加即可.
    • x<kx\lt k,可以转化为在节点为 klsonk_{lson} 为根的子树下插入元素 xx
    • x>kx \gt k,可以转化为在节点为 krsonk_{rson} 为根的子树下插入元素 xx

最后,在统计 size 时,可以使用类似于线段树的 pushup 方法,将左右子树的 size 相加即可.

时间复杂度为 O(h)O(h)hh 为高度,最坏 O(n)O(n)

由于有删除有新增,所以需要一个函数来动态管理内存.

以下代码使用递归进行插入:

查询排名#

依旧的,进行一次搜索.

如果你想要查询 xx 在树中是排第几位的,那么只需要算出比 xx 小的数有几个,而后,将结果加一,即为排名.

特殊的,如果 xx 不存在,则假想 xx 存在,即定义 xx 的排名为比 xx 小的数的个数加一.

为了统计比 xx 小的数有几个,如果你当前遍历到节点 kk,那么有以下三种情况:

  • x<kx \lt k,比 xx 小的数一定都在 kk 的左子树上,可以舍弃右子树,继续遍历左子树.
  • x=kx=k,其实和 x<kx \lt k 的情况是一样的.
  • x>kx \gt k,此时左子树中的一定都是比 xx 小的,右子树中有部分.将左子树和 kk 计入答案,继续遍历右子树.
int rank(int x) {
	int now = ROOT;
	int ans = 0;
	while (now) {
		if (x <= node[now].v) {
			now = node[now].lson;
		}
		else if (x > node[now].v) {
			ans += node[now].lsize() + node[now].num;
			now = node[now].rson;
		}
	}
	return ans + 1;
}
cpp

查询第几大#

为了查询第 xx 大的数,如果你当前遍历到节点 kk,若定义 kk 的左子树大小为 klsizek_{lsize},那么有以下三种情况:

  • xklsizex \le k_{lsize},即第 xx 大的数在 kk 的左子树,只需要继续遍历左子树即可.
  • klsize>xklsize+knumk_{lsize} \gt x \le k_{lsize} + k_{num},即第 xx 大的数就是 kk
  • 否则,第 xx 大的数在 kk 的右子树,将 xx 减去左子树和 kk 点的数量后,继续遍历右子树.

查询前驱/后继#

计算前驱(即最大的比 xx 小的数),可以得知 xx 的排名后,再将排名减一后用 kth 查询即可.

计算后继(即最小的比 xx 大的数),显然,后继一定比原数至少大 1,可以通过得知 x+1x+1 的排名后,通过这个排名来用 kth 查询.

int pre(int x) {
	return kth(rank(x) - 1);
}
int succ(int x) {
	return kth(rank(x + 1));
}
cpp

删除#

为了删除 xx,如果你当前遍历到节点 kk,那么有以下三种情况:

  • x<kx \lt kxx 一定在 kk 的左子树上,可以继续遍历左子树.
  • x>kx \gt kxx 一定在 kk 的右子树上,可以继续遍历右子树.
  • x=kx=k
    • 如果 knum1k_{num} \ne 1,即还有多个此种数字,那么直接让 knum1k_{num}-1 即可.
    • 否则,就需要删除这个节点
      • 如果节点 kk 没有子节点,直接删
      • 如果节点 kk 只有左子节点或右子节点,那么将其子节点覆盖父节点.
      • 如果 kk 有两个子节点,那么,寻找 kk 的前驱或后继,将其覆盖节点 kk(注意,一定是覆盖而非替换,因为如果替换就会破坏平衡树的大小规则).可以证明,节点 kk 的前驱和后继分别在 kk 的左子树和右子树上.并且,kk 的前驱和后继必然大于原来 kk 的左子树上的节点且小于原来 kk 右子树上的节点.

时间复杂度#

每一项操作的时间复杂度都是 O(h)O(h),其中 hh 是深度,共有 nn 次操作,综合最坏 O(n2)O(n^2)

标程#

二叉平衡树#

显然最坏 O(n2)O(n^2) 的时间复杂度无法通过,所以需要一些优化.

替罪羊树#

很显然,之所以会有最坏 O(n2)O(n^2),是因为失衡,使其退化成了接近链的形式.

因此,只需要在插入之后进行一次重构,就可以解决这个问题.

究竟在何时重构呢?在这里要引入一个常数 α(0.5,1)\alpha \in (0.5, 1)(常取 0.75),当左子树节点数 siz1siz_1 和右子树节点数 siz2siz_2 和当前字数总节点数 siz0siz_0 满足 max(siz1,siz2)>αsiz0max(siz_1, siz_2) > \alpha siz_0 时,需要重构.

为了实现,我们需要定义:

在插入时,我们需要在回溯时判断这个节点是否平衡,如果不平衡,将其记录.

每一次插入,只有一个节点,即最上面的一个不平衡的节点需要被视为重构的根节点.

main 函数中处理 insert 的返回值.

if (opt == 1) {
	int ref_node = tree.insert(x, tree.ROOT);
	if (ref_node != null) {
		tree.rebuild(ref_node);
	}
}
cpp

当需要重构时,调用重构函数.重构主要需要完成以下几件事:

  • 获取中序遍历结果.
  • 删除子树中的所有节点.
  • 用像线段树一样的方法去重建树.

时间复杂度#

由于使用了重构,所以单次操作时间复杂度是均摊 O(logn)O(\log n) 的,总时间复杂度 O(nlogn)O(n\log n)

标程#

堆与平衡树
https://blog.jerrylab.top/blog/ds/heap-and-tree
Author Jerry
Published at 2026年3月16日