Unverified Commit 36febdfa authored by ir1d's avatar ir1d Committed by GitHub
Browse files

Merge pull request #937 from WAAutoMaton/master

feat: add 配对堆
parents ba752481 9cd48aa5
Loading
Loading
Loading
Loading

docs/ds/binary-heap.md

0 → 100644
+119 −0
Original line number Diff line number Diff line
### 结构

从二叉堆的结构说起,它是一棵二叉树,并且是完全二叉树,每个结点中存有一个元素(或者说,有个权值)。

堆性质:父亲的权值不大于儿子的权值(小根堆)。

由堆性质,树根存的是最小值(getmin 操作就解决了)。

### 插入操作

首先,要保证插入后也是一棵完全二叉树。

最简单的方法就是,最下一层最右边的叶子之后插入。

如果最下一层已满,就新增一层。

插入之后可能会不满足堆性质?

向上调整:如果这个结点的权值大于它父亲的权值,就交换,重复此过程直到不满足或者到根。

可以证明,插入之后向上调整后,没有其他结点会不满足堆性质。

向上调整的时间复杂度是 $O(\log n)$ 的。

### 删除操作

删除根结点。

如果直接删除,则变成了两个堆,难以处理。

所以不妨考虑插入操作的逆过程,设法将根结点移到最后一个结点,然后直接删掉。

然而实际上不好做,我们通常采用的方法是,把根结点和最后一个结点直接交换。

于是直接删掉(在最后一个结点处的)根结点,但是新的根结点可能不满足堆性质……

向下调整:在该结点的所有儿子中,找一个最小的,与该结点交换,重复此过程直到底层。

可以证明,删除并向下调整后,没有其他结点不满足堆性质。

时间复杂度 $O(\log n)$ 。

### 减小某个点的权值

很显然,直接修改后,向上调整一次即可,时间复杂度为 $O(\log n)$ 。

### 实现

我们发现,上面介绍的几种操作主要依赖于两个核心:向上调整和向下调整。

(伪代码)

```c++
up(x) {
  while (x > 1 && h[x] > h[x / 2]) {
    swap(h[x], h[x / 2]);
    x /= 2;
  }
}
down(x) {
  while (x * 2 <= n) {
    t = x * 2;
    if (t + 1 <= n && h[t + 1] < h[t]) t++;
    if (h[t] >= h[x]) break;
    swap(h[x], h[t]);
    x = t;
  }
}
```

### 建堆

考虑这么一个问题,从一个空的堆开始,插入 $n$ 个元素,不在乎顺序。

直接一个一个插入需要 $O(n \log n)$ 的时间,有没有更好的方法?

#### 方法一:使用 decreasekey(即,向上调整)

从根开始,按 BFS 序进行。

```text
build_heap_1() {
	for (i = 1; i <= n; i++) up(i);
}
```

为啥这么做:对于第 $k$ 层的结点,向上调整的复杂度为 $O(k)$ 而不是 $O(\log n)$ 。

总复杂度: $\log 1 + \log 2 + \cdots + \log n = \Theta(n \log n)$ 。

(在「基于比较的排序」中证明过)

#### 方法二:使用向下调整

这时换一种思路,从叶子开始,逐个向下调整

```text
build_heap_2() {
	for (i = n; i >= 1; i--) down(i);
}
```

换一种理解方法,每次「合并」两个已经调整好的堆,这说明了正确性。

注意到向下调整的复杂度,为 $O(\log n - k)$ 。

$$
\begin{aligned}
总复杂度 & = n \log n - \log 1 - \log 2 - \cdots - \log n \\
& \leq n \log n - 0 \times 2^0 - 1 \times 2^1 -\cdots - (\log n - 1) \times \frac{n}{2} \\\
& = n \log n - (n-1) - (n-2) - (n-4) - \cdots - (n-\frac{n}{2}) \\
& = n \log n - n \log n + 1 + 2 + 4 + \cdots + \frac{n}{2} \\
& = n - 1 \\ &  = O(n)
\end{aligned}
$$

之所以能 $O(n)$ 建堆,是因为堆性质很弱,二叉堆并不是唯一的。

要是像排序那样的强条件就难说了。
+11 −136
Original line number Diff line number Diff line
## 堆
# 堆简介

堆是一种数据结构,维护一个数的集合(或者,一个支持比较的元素的集合)。

@@ -12,140 +12,15 @@

## 堆的分类

一个有趣的事实是,这些堆都是用基于树的数据结构实现的。

在 NOIP 中,我们只要求一个能支持主要操作的堆就行,也就是二叉堆。

-   二叉堆_(binary heap)_

最基础的堆,不支持 merge 和可持久化,所有操作的复杂度都是 $O(\log n)$ 的。

-   二项堆_(binomial heap)_

支持 merge 的堆,(也能可持久化),所有操作的复杂度都是 $O(\log n)$ 。

-   Fib 堆_(Fibonacci heap)_

除了不能可持久化,支持全部功能,而且除了 deletemin 以外都是均摊 $O(1)$ 的。

## 二叉堆

### 结构

从二叉堆的结构说起,它是一棵二叉树,并且是完全二叉树,每个结点中存有一个元素(或者说,有个权值)。

堆性质:父亲的权值不大于儿子的权值(小根堆)。

由堆性质,树根存的是最小值(getmin 操作就解决了)。

### 插入操作

首先,要保证插入后也是一棵完全二叉树。

最简单的方法就是,最下一层最右边的叶子之后插入。

如果最下一层已满,就新增一层。

插入之后可能会不满足堆性质?

向上调整:如果这个结点的权值大于它父亲的权值,就交换,重复此过程直到不满足或者到根。

可以证明,插入之后向上调整后,没有其他结点会不满足堆性质。

向上调整的时间复杂度是 $O(\log n)$ 的。

### 删除操作

删除根结点。

如果直接删除,则变成了两个堆,难以处理。

所以不妨考虑插入操作的逆过程,设法将根结点移到最后一个结点,然后直接删掉。

然而实际上不好做,我们通常采用的方法是,把根结点和最后一个结点直接交换。
|         操作\\数据结构        |                                    配对堆                                    |       二叉堆      |       左偏树      |      二项堆      | 斐波那契堆         |
| :---------------------: | :-----------------------------------------------------------------------: | :------------: | :------------: | :-----------: | ------------- |
|        插入(insert)       |                                   $O(1)$                                  |   $O(\log n)$  |   $O(\log n)$  |     $O(1)$    |  $O(1)$       |
|     查询最小值(find-min)     |                                   $O(1)$                                  |     $O(1)$     |     $O(1)$     |  $O(\log n)$  |  $O(1)$       |
|    删除最小值(delete-min)    |                                $O(\log n)$                                |   $O(\log n)$  |   $O(\log n)$  |  $O(\log n)$  |  $O(\log n)$  |
|        合并 (merge)       |                                   $O(1)$                                  |     $O(n)$     |   $O(\log n)$  |  $O(\log n)$  |  $O(1)$       |
| 减小一个元素的值 (decrease-key) |  $o(\log n) (下界\Omega(\log \log n) ,\\\\上界 O(2^{2\sqrt{\log \log n}}) )$  |   $O(\log n)$  |    $O(logn)$   |  $O(\log n)$  |  $O(1)$       |
|         是否支持可持久化        |                                  $\times$                                 |  $\checkmark$  |  $\checkmark$  |               |  $\times$     |

于是直接删掉(在最后一个结点处的)根结点,但是新的根结点可能不满足堆性质……

向下调整:在该结点的所有儿子中,找一个最小的,与该结点交换,重复此过程直到底层。

可以证明,删除并向下调整后,没有其他结点不满足堆性质。

时间复杂度 $O(\log n)$ 。

### 减小某个点的权值

很显然,直接修改后,向上调整一次即可,时间复杂度为 $O(\log n)$ 。

### 实现

我们发现,上面介绍的几种操作主要依赖于两个核心:向上调整和向下调整。

(伪代码)

```c++
up(x) {
  while (x > 1 && h[x] > h[x / 2]) {
    swap(h[x], h[x / 2]);
    x /= 2;
  }
}
down(x) {
  while (x * 2 <= n) {
    t = x * 2;
    if (t + 1 <= n && h[t + 1] < h[t]) t++;
    if (h[t] >= h[x]) break;
    swap(h[x], h[t]);
    x = t;
  }
}
```

### 建堆

考虑这么一个问题,从一个空的堆开始,插入 $n$ 个元素,不在乎顺序。

直接一个一个插入需要 $O(n \log n)$ 的时间,有没有更好的方法?

#### 方法一:使用 decreasekey(即,向上调整)

从根开始,按 BFS 序进行。

```text
build_heap_1() {
	for (i = 1; i <= n; i++) up(i);
}
```

为啥这么做:对于第 $k$ 层的结点,向上调整的复杂度为 $O(k)$ 而不是 $O(\log n)$ 。

总复杂度: $\log 1 + \log 2 + \cdots + \log n = \Theta(n \log n)$ 。

(在「基于比较的排序」中证明过)

#### 方法二:使用向下调整

这时换一种思路,从叶子开始,逐个向下调整

```text
build_heap_2() {
	for (i = n; i >= 1; i--) down(i);
}
```

换一种理解方法,每次「合并」两个已经调整好的堆,这说明了正确性。

注意到向下调整的复杂度,为 $O(\log n - k)$ 。

$$
\begin{aligned}
总复杂度 & = n \log n - \log 1 - \log 2 - \cdots - \log n \\\\
& \leq n \log n - 0 \times 2^0 - 1 \times 2^1 -\cdots - (\log n - 1) \times \frac{n}{2} \\\\
& = n \log n - (n-1) - (n-2) - (n-4) - \cdots - (n-\frac{n}{2}) \\\\
& = n \log n - n \log n + 1 + 2 + 4 + \cdots + \frac{n}{2} \\\\
& = n - 1 \\\\ &  = O(n)
\end{aligned}
$$

之所以能 $O(n)$ 建堆,是因为堆性质很弱,二叉堆并不是唯一的。
一个有趣的事实是,这些堆都是用基于树的数据结构实现的。

要是像排序那样的强条件就难说了
在 OI 中,最常用的堆是二叉堆,习惯上不加限定提到“堆”时往往也指二叉堆
+13.8 KiB
Loading image diff...
+12.2 KiB
Loading image diff...
+35 KiB
Loading image diff...
Loading