最大堆最小堆及堆排序
堆这个数据结构在我大学的教材上没有讲解,但平时听说过堆排序什么的,无疑是要用到这个数据结构,所以本篇文章主要是总结下堆的概念和实现。
堆概念
在维基百科中,是这样定义堆的:
堆(英语:Heap)是计算机科学中的一种特别的树状数据结构。若是满足以下特性,即可称为堆:“给定堆中任意节点 P 和 C,若 P 是 C 的母节点,那么 P 的值会小于等于(或大于等于) C 的值”。若母节点的值恒小于等于子节点的值,此堆称为最小堆(min heap);反之,若母节点的值恒大于等于子节点的值,此堆称为最大堆(max heap)。在堆中最顶端的那一个节点,称作根节点(root node),根节点本身没有母节点(parent node)。
从上面的定义我们可以了解到,堆分为最大堆和最小堆,即中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。堆的实现通过构造二叉堆(binary heap),实为二叉树的一种。所以一个堆要满足以下性质:
- 堆总是一棵完全二叉树
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值
为什么要为完全二叉树呢?我们知道完全二叉树除最后一层外,其它层的节点个数都是满的,最后一层节点都靠左排列,如下图所示:
堆性质
我们知道完全二叉树适合用数组来存储,不会出现空闲的位置,堆也是用数组来实现的,假设现在我们有以下数组:
{12, 6, 4, 9, 1, 5, 14, 3}
如果我们要将这个数据插入到堆(最大堆)里面,那么按照堆的性质,最终的形式如下:
上面的二叉树很明显是完全二叉树,数据是存储在数组里面的,数组的第一个位置我们空着不用,主要是为了计算方便。从图中我们可以看到,数组中下标为
对于堆的常用操作,包括插入和删除堆顶元素,下面我们以最大堆来为例,最小堆就不说了。
最大堆
根据上面的组总结,我们先将获取节点位置的方法实现一下:
/** * 根据给定节点的下标,找到其左儿子的下标 * @param i 给定节点的下标 * @return 其左儿子的下标 */ public int left(int i) { return 2 * i; } /** * 根据给定节点的下标,找到其右儿子的下标 * @param i 给定节点的下标 * @return 其右儿子的下标 */ public int right(int i) { return 2 * i + 1; } /** * 根据给定节点的下标,找到其父节点的下标 * * @param i 给定节点的下标 * @return 其父节点的下标 */ public int parent(int i) { // 为根节点 if (i == 0) return -1; return i / 2; }
然后定义类中的属性,包括数据存储的数组、容量和当前元素个数。接着我们实现构造函数,如下所示:
private T[] data; // 数据存储 private int count; // 堆目前所含数据量大小 private int capacity; // 堆容量大小 public MaxHeap(int capacity) { this.capacity = capacity; this.count = 0; this.data = (T[]) new Comparable[capacity + 1]; } public MaxHeap(T[] data, int capacity) { if (capacity <= 0) { throw new IllegalArgumentException("capacity must not be 0"); } if (capacity <= data.length) { throw new IllegalArgumentException("capacity must greater than the length of data"); } this.capacity = capacity; this.count = data.length; buildHeap(data); } private void buildHeap(T[] array) { data = (T[]) new Comparable[capacity]; this.count = array.length; System.arraycopy(array, 0, data, 1, count); for (int k = count; k > 0 ; k--) { heapUp(k); } }
插入元素
对于插入操作,我们要保证在插入元素之后,堆依然要保证其两个性质。我们先将元素添加到数组的末尾,也就是完全二叉树的最后一层的空节点位置,如下所示:
如上图所示,现在我们要插入20这个元素,放在了6元素的右子结点上,但现在明显不满足堆的第二个性质,6比20小,所以我们需要进行调整,将6和20的位置互换,如果还不满足,那就继续,使其满足堆的两个性质,这个过程被称为堆化(heapify)。这里的堆化过程是从下向上的堆化过程。
这个过程其实是比较好理解的,下面我们来用代码实现一下:
public void add(T value) { Objects.requireNonNull(value, "value must not be null"); if (count >= capacity - 1) return; ++count; this.data[count] = value; heapUp(count); }
heapUp这个方法就是调整堆中的元素,使其满足堆的性质,过程比较简单:
/** * 上浮 */ private void heapUp(int index) { checkPosition(index); int nodeIndex = index; T value = this.data[nodeIndex]; if (value == null) return; while (nodeIndex > 0 && parent(nodeIndex) >0 && data[nodeIndex].compareTo(data[parent(nodeIndex)]) > 0) { swap(nodeIndex, parent(nodeIndex)); nodeIndex = parent(nodeIndex); } } /** * 交换两个位置的数据 * * @param index1 * @param index2 */ private void swap(int index1, int index2) { checkPosition(index1); checkPosition(index2); T tempValue = this.data[index1]; this.data[index1] = this.data[index2]; this.data[index2] = tempValue; }
删除堆顶元素
由于堆的特殊性质,堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值,所以堆中的最大值(最小值)必在堆顶,现在我们把堆顶元素删除了,就不是二叉树了,一个解决的方式是:将数组末尾的元素移动到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的结点,此时元素应该和子节点比较,如果大于等于子节点或者没有子节点,停止比较;否则,选择子节点中最大的元素,进行交换,执行此步,直到结束。这个过程被称为自上而下的堆化过程,如下所示:
代码如下:
public T remove() { if (count == 0) return null; T value = this.data[1]; this.data[1] = this.data[this.count]; this.data[this.count] = null; --this.count; heapDown(count, 1); return value; }
heapDown为自上而下的堆化过程:
/** * 下沉 * * @param n 总数量 * @param i 当前节点位置 */ private void heapDown(int n, int i) { while (true) { int maxPos = i; int left = left(i); int right = right(i); if (left <= n && this.data[i].compareTo(this.data[left]) < 0) maxPos = left; if (right <= n && this.data[maxPos].compareTo(this.data[right]) < 0) maxPos = right; if (maxPos == i) break; swap(i, maxPos); i = maxPos; } }
堆排序
上面说了关于堆的概念及其实现,那么我们可以利用堆做些什么呢? 我们可以利用堆来做排序(原地排序)。排序有升序与降序之分,相应的最大堆通常被用来进行"升序"排序,而最小堆通常被用来进行"降序"排序。这是为何?主要是堆排序算法利用堆的特性,比如最大堆的堆顶元素最大,最小堆堆顶元素最小。下面我们来根据最大堆来分析下。
堆排序(升序)的基本思路是:建堆和排序。简单概括如下:
- 建堆:将数列a[1...n]构造成最大堆。
- 排序:将a[1]和a[n]交换,使a[n]是a[1...n]中的最大值;然后将a[1...n-1]重新调整为最大堆。 接着,将a[1]和a[n-1]交换,使a[n-1]是a[1...n-1]中的最大值;然后将a[1...n-2]重新调整为最大值。 依次类推,直到整个数列都是有序的。
建堆
所谓原地排序,就是我们不要再利用其他数组了,直接使用给定的数组,就可以进行排序了。所以我们建堆也是原地建堆,不利用其他数组。这里最容易想到的就是前面的入堆操作,假设最初堆中就只有一个元素,然后再有元素过来了,就相当于插入操作,然后按照堆的特性自下向上进行调整。不过这种需要每个都进行调整,还有一种比较好的方式来实现。
记得前面我们删除堆顶元素的操作吗?删除堆顶元素时采用自上而下的操作,其实我们只要从第一个非叶子结点开始,从后往前处理数组,并且每个数据都是从上往下堆化的。对于完全二叉树,假设根结点编号从1开始,那么它的第一个叶子节点的编号必为
/** * 原地建堆 */ public static void buildMaxHeap(int[] data, int n) { // 从第一个非叶子结点开始调整 for (int i = n / 2; i >= 1; --i) { heapify(data, n, i); } } /** * 堆化 * * @param data * 数组 * @param n * 总数量 * @param i * 当前位置 */ private static void heapify(int[] a, int n, int i) { while (true) { int maxPos = i; int left = left(i); int right = right(i); if (left <= n && a[i] < a[left]) maxPos = left; if (right <= n && a[maxPos] < a[right]) maxPos = right; if (maxPos == i) break; swap(a, i, maxPos); i = maxPos; } }
排序
建好堆后,接下来我们就可以来排序了,对于升序来说,由于堆顶元素就是最大元素,所以每次操作时,我们需要将堆顶元素移动到数组末尾,将原数组末尾的元素移动到堆顶,然后将前面的元素重新堆化,使其满足堆的特性,就这样依次操作,知道排序完成。过程是比较简单的,代码如下:
/** * 排序 */ public static void sort(int[] data, int n) { buildMaxHeap(data, n); int k = n; while (k > 1) { swap(data, 1, k); --k; heapify(data, k, 1); } } /** * 交换两个位置的数据 * * @param data * 数组 * @param index1 * 位置1 * @param index2 * 位置2 */ private static void swap(int[] data, int index1, int index2) { int tempValue = data[index1]; data[index1] = data[index2]; data[index2] = tempValue; }
时间复杂度
建堆的时间复杂度为
References:
title: 最大堆最小堆及堆排序
tags: [数据结构, 堆]
author: Mingshan
categories: [数据结构, 堆]
date: 2019-05-14
mathjax: true
本文来自博客园,作者:mingshan,转载请注明原文链接:https://www.cnblogs.com/mingshan/p/17793540.html
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· DeepSeek “源神”启动!「GitHub 热点速览」
· 上周热点回顾(2.17-2.23)