本文一起来研究个常见算法,但是你不一定会 。
什么是小顶堆小顶堆是一种经过排序的完全二叉树, 其满足如下性质:
- 小顶堆中的任意父节点都比其两个孩子结点小
由上方性质又可以推导出如下性质:
- 小顶堆的根节点为整个堆元素中最小的元素
我们当然可以用面向对象的方式描述一颗二叉树, 但是有没有不浪费一丁点空间. 即除了元素本身开销外, 尽量不额外占用内存空间的描述方式呢?
有的, 我们可以把小顶堆装入数组中. 为了把小顶堆装入数组中, 我们需要给出一个数组中的元素, 就能计算出其对应的父结点, 以及其两个子节点对应的位置 (这样我们就能定位到小顶堆中所有元素的位置, 可以操作任意一个元素, 这样就达到用数组描述小顶堆的目的啦).
装入数组的小顶堆元素位置满足如下性质:
- 假设有结点m在数组中下标为n, 那么其左孩子结点下标为2n+1, 右孩子结点下标为2n+2
通过上面的介绍, 我们现在知道一个装在数组中的小顶堆要满足如下三个性质:
- 小顶堆中的任意父节点都比其两个孩子结点小
- 小顶堆的根节点为整个堆元素中最小的元素
- 假设有结点m在数组中下标为n, 那么其左孩子结点下标为2n+1, 右孩子结点下标为2n+2
这三个性质第一点是元素构成小顶堆的基本性质, 衍生出的第二点性质是利用小顶堆对无序元素进行堆排序的关键, 第三点性质是为了将小顶堆装入到数组中.
来一根数组, 以及一打元素下酒假设我们有一个长度为10的无序数组:
[5, 2, 1, 9, 6, 7, 3, 4, 0, 8]
按照性质3我们可以将无序数组等价还原成一颗完全二叉树:
5 / \ 2 1 / \ / \ 9 6 7 3 / \ /4 0 8
我们的目标是将其元素排序, 构造成符合性质1和2的完全二叉树, 即小顶堆(同一个完全二叉树排序后小顶堆有多种情况, 下方展示其中一种) 。
排序后构造成小顶堆的数组:[0, 2, 1, 4, 6, 7, 3, 5, 9, 8]
也就是下方所示的小顶堆:
0 / \ 2 1 / \ / \ 4 6 7 3 / \ /5 9 3排序! 每个元素都有属于自己的位置
以下方的完全二叉树为例, 我们分析一下, 如何调整元素顺序, 来将其构造成小顶堆呢.
5 / \ 2 1 / \ / \ 9 6 7 3 / \ /4 0 8
根据性质1, 我们要让所有的父节点都比其直接子节点小, 也就是说我们要把二叉树中每一个子树的父节点与其子节点比较, 如果父节点比子节点大, 那么将父节点与其交换, 使得子树满足最小堆的性质.
那么父节点要与孩子结点比较几次呢, 是存在几个孩子就比较几次吗, 其实比较一次就可以, 我们先让两个孩子结点比较, 找到较大的一个, 然后让父节点与较大的比较, 如果父节点比较大的子节点大, 那么它肯定也比较小的子节点大, 将父节点与较大的子节点交换, 那么此时子树就满足最小堆性质.
将整颗二叉树, 最后一个拥有子节点的父节点进行上述调整, 当从后往前处理完所有节点后, 整颗二叉树都满足最小堆性质, 那么就完成了最小堆的构建. 整个过程看起来像是元素在下沉, 比如根节点5, 最终下沉到了4的位置.
上代码接下来的代码展示了如何构建小顶堆, 依赖了lombok与junit, 放在IDE里调试运行可以理解的更清晰.
/**堆排序
* 最小堆的定义是父节点一定比其两个直接子节点要大
* 根节点一定是所有元素中最小的, 依据这个性质可以从前往后不断构建小顶堆, 最终整个数组元素升序排列, 这就是堆排序
*
* @author Gaozl
* @date 2020/6/10 17:00
*/
@Slf4j
public class MinHeap {
/**
* 元素数组, 需要先了解下如何用数组表示二叉树
* 数组表示的二叉树有如下特性
* 假设有结点m下标为n, 那么2n+1是结点m的左孩子, 2n+2是结点m的右孩子
*/
private Integer[] elements = {5, 2, 1, 9, 6, 7, 3, 4, 0, 8};
/**
* 尝试将给定元素放到指定下标, 如果不符合小顶堆特性将调整结点位置直到符合
* @param index 元素尝试放入的下标
* @param element 给定元素
* @param length 用数组前几个元素构建小顶堆
*/
public void siftDown(int index, Integer element, int length) {
int half = length >>> 1;
while (index < half) {
int leftChild = 2 * index + 1;
int rightChild = 2 * index + 2;
// 比左右两个孩子中较小的孩子大才需要下沉, 因为比较小孩子小时肯定也比较大的孩子小, 此时比两个孩子都小无需下沉
int compareChild = (rightChild < length) // 存在rightChild
&& elements[rightChild] < elements[leftChild] ? rightChild : leftChild;
// 当前元素比孩子大, 需要下沉
if (element > elements[compareChild]) {
elements[index] = elements[compareChild];
index = compareChild;
} else { // 否则无需下沉, 停止处理
break;
}
}
elements[index] = element;
}
/**
* 将数组构建成小顶堆
*/
public void heapify(int length) {
for (int i = (length >>> 1) - 1; i >= 0; i--) { // 遍历所有非叶子结点
siftDown(i, elements[i], length); // 将每个非叶子结点调整到符合小顶堆性质后, 整个数组自然构建成了小顶堆
}
}
/**
* 用数组构建小顶堆
*/
@Test
public void testHeapify() {
log.info("{}", toString());
heapify(elements.length);
log.info("{}", toString());
}
@Override
public String toString() {
return Arrays.toString(elements);
}
}
既然我们都了解了如何构建小顶堆, 那么可以进一步了解一下很有意思的排序方法, 堆排序. 堆排序依赖堆的第2条性质. 直接上代码吧:)
/**拓展
* 堆排序, 降序是用小顶堆, 小顶堆每次找到最小的元素并下沉.
* 升序是用大顶堆, 大顶堆每次找到最大元素并下沉
*/
@Test
public void testDescendSort() {
log.info("{}", toString());
// 先将数组所有元素构建小顶堆, 然后将堆顶最小元素与小顶堆最后一个元素交换
// 排除此时最后一个最小的元素, 前面元素继续构建小顶堆, 找到最小元素继续交换到此时小顶堆最后一个元素
// 经过构建length次小顶堆, 此时数组里所有元素已经是降序排列
for (int i = 0; i < elements.length; i++) {
int length = elements.length - i;
heapify(length);
int temp = elements[length - 1];
elements[length - 1] = elements[0];
elements[0] = temp;
}
log.info("{}", toString());
}
本篇文章的构建小顶堆代码并不是我凭空想象出来的, 是JDK1.8中优先队列PriorityQueue类中的代码片段改造注释而来. 既然聪明的你已经读到了这里, 那么可以顺便去看看PriorityQueue源代码实现啦, 如果还是看不懂也没关系, 请参考如下开源项目, 超级方便哦, 一个致力于帮大家节约阅读JDK源码时间的开源项目 。
https://github.com/gaozhilai/open-jdk1.8-analysis