程序员基本功系列6——堆
堆是一种特殊类型的树,这种数据结构应用场景非常多,最经典的莫过于堆排序,堆排序是一种原地排序,它的时间复杂度是 O(nlogn)。
前面提到的快速排序,平均情况下时间复杂度也是 O(nlogn),甚至堆排序比快速排序的时间复杂度还要稳定,但是实际开发中,快速排序要比堆排序好,这是为什么呢?带着这个问题我们来看一下堆这个数据结构。
1、堆的基础
1.1、定义
堆是一种特殊类型的树结构,它需要满足两个条件:
• 堆是一个完全二叉树
• 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。对于每个节点的值都大于等于子树中每个节点值的堆,叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,叫做“小顶堆”。
来看几个例子:其中1和2是大顶堆,3是小顶堆,4不是堆。
1.2、实现
前面介绍二叉树的时候提到过,完全二叉树适合用数组来存储,非常节省内存,所以一般堆由数组来实现,如下:
数组中下标为 i 的节点,其左节点就是下标为 i*2 的节点,其右节点就是下标为 i*2+1 的节点,其父节点就是下标为 i/2 的节点。
了解堆的存储,来看下堆操作:
(1)插入元素
如下,向堆中插入元素22,如果直接添加到数组后面,那就破坏了堆的特性,所以需要进行调整,这个过程叫做堆化,堆化就是顺着节点所在的路径,向上或者向下,比较然后交换。
堆化分为从上往下和从下往上,先来看从下往上的堆化。
根据上面分解图写出代码:
public class Heap { private int[] a; // 数组,从下标1开始存储数据 private int n; // 堆可以存储的最大数据个数 private int count; // 堆中已经存储的数据个数 public Heap(int capacity) { a = new int[capacity + 1]; n = capacity; count = 0; } public void insert(int data) { if (count >= n) return; // 堆满了 ++count; a[count] = data; int i = count; while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化 swap(a, i, i/2); // swap()函数作用:交换下标为i和i/2的两个元素 i = i/2; } } }
(2)删除堆顶元素
根据堆的定义,堆顶元素就是整个堆中最大或最小元素,已大顶堆为例,如果删除堆顶元素,就要把第二大元素放到堆顶元素,然后迭代删除第二大节点,分解图如下:
但是这样会出现空缺的数组空间,也可能破坏完全二叉树这一特性。所以换种思路:最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。
根据分解图写出代码:
public void removeMax() { if (count == 0) return -1; // 堆中没有数据 a[1] = a[count]; --count; heapify(a, count, 1); } private void heapify(int[] a, int n, int i) { // 自上往下堆化 while (true) { int maxPos = i; if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2; if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1; if (maxPos == i) break; swap(a, i, maxPos); i = maxPos; } }
利用堆的建堆和删除堆顶元素解决数组中查找第k大元素的问题:leetcode 215题。
class Solution { public int findKthLargest(int[] nums, int k) { int heapLen = nums.length; //建堆 buileMaxHeap(nums,heapLen); //k-1次删除操作 for(int i=nums.length-1;i>=nums.length-k+1;--i){ //删除栈顶元素 swap(nums,0,i); --heapLen; //重新堆化 heapify(nums,0,heapLen); } return nums[0]; } private void buileMaxHeap(int[] nums,int n){ //从最后一个叶子节点的父节点开始堆化 for(int i=n/2;i >= 0;--i){ heapify(nums,i,n); } } private void heapify(int[] nums,int i,int n){ int left = i*2+1; int right = i*2+2; int maxPos = i; //比较左节点 if(left < n && nums[maxPos] < nums[left]){ maxPos = left; } //比较右节点 if(right < n && nums[maxPos] < nums[right]){ maxPos = right; } //当前节点不是最大节点,继续向下寻找 if(maxPos != i){ swap(nums,maxPos,i); heapify(nums,maxPos,n); } } private void swap(int[] nums,int a,int b){ int tmp = nums[a]; nums[a] = nums[b]; nums[b] = tmp; } }
(3)插入和删除的时间复杂度分析
一个完全二叉树的树高不会超过logn,插入和删除的主要逻辑就是堆化,堆化是顺着节点所在路径比较和交换,所以堆化的时间复杂度与树高成正比,即堆插入和删除的时间复杂度就是 O(logn)。
2、堆排序
前面介绍排序的时候提到了时间复杂度为 O(n2)的冒泡排序、插入排序、选择排序,还有时间复杂度为 O(nlogn)的归并排序、快速排序。现在说的堆排序时间复杂度也是 O(nlogn),并且是原地排序。
堆排序分解为两大步骤:建堆和排序。
(1)建堆
将原数组原地建堆,可以从后向前进行数据处理,每个数据都是从上往下进行堆化。来看下分解图:
先看下代码,再具体分析实现逻辑:
/** * 建堆 */ private static void buildHeap(int[] arr){ //对于完全二叉树,(arr.length-1) / 2是最后一个叶子节点的父节点,叶子节点不用堆化 for(int i=(arr.length-1)/2;i >= 0;i--){ heapify(arr,arr.length-1,i); } } /** * 堆化, */ private static void heapify(int[] arr,int n,int i){ //当前节点小标i,那它的左子节点就是i*2,右子节点就是i*2+1 while (true){ int maxPos = i; //与左子节点比较,获取最大值位置 if (i*2 <= n && arr[i] < arr[i*2]){ maxPos = i*2; } //与右子节点比较,获取最大值位置 if (i*2+1 <= n && arr[maxPos] < arr[i*2+1]){ maxPos = i*2+1; } //最大值是当前位置,结束循环 if (maxPos == i){ break; } //与子节点交换位置 swap(arr,i,maxPos); //以交换后子节点位置继续往下寻找 i = maxPos; } }
在代码中,对下标从 n/2 到 1 的数据进行堆化,因为对于完全二叉树来说,n/2+1 到 n 的节点都是叶子节点,所以不需要进行堆化。
建堆的时间复杂度是 O(n)。
(2)排序
根据上面的代码,建堆之后,数组中的数据已经是按照大顶堆的特性来组织的,数组中的第一个元素就是堆顶,也就是最大的元素。把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。原来下标为 n 的位置元素放在了堆顶位置,再通过堆化的方法,将剩下 n-1 个元素重新构建成堆,然后重复这个过程,有点类似元删除堆顶的过程,直到最后堆中只剩下标为 1 的元素,整个排序就完成了。
根据这个过程写出代码:
private static void sort(int[] arr){ //1、建堆 buildHeap(arr); //2、排序 int k = arr.length-1; while (k > 0){ //将堆顶元素(最大)与最后一个元素交换位置 swap(arr,k, 0); //将剩下的元素重新堆化 heapify(arr,--k,0); } }
(3)时间复杂度分析
堆排序分为建堆和排序两个过程,建堆的时间复杂度是 O(n),排序的时间复杂度是 O(nlogn),所以总体来说堆排序的时间复杂度是 O(nlogn)。
另外堆排序不是稳定的排序,因为存在将堆顶元素和末尾元素交换,可能破坏相同数值的元素原来的位置。
3、解答开篇
在实际开发中,为什么快速排序比堆排序性能好?主要有两点原因:
(1)快排比堆排数据访问方式要友好
快速排序是分区局部访问,但是堆排是跳着访问的,所以对CPU缓存不友好。
(2)对于同样的数据,堆排数据交换次数比快排多
对于快排,数据有序度越高,交换次数越少,但是堆排在建堆过程中会打乱原先数组的顺序。