编程语言
首页 > 编程语言> > 程序员基本功系列6——堆

程序员基本功系列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;
  }
}

(3)插入和删除的时间复杂度分析

  一个完全二叉树的树高不会超过logn,插入和删除的主要逻辑就是堆化,堆化是顺着节点所在路径比较和交换,所以堆化的时间复杂度与树高成正比,即堆插入和删除的时间复杂度就是 O(logn)。

2、堆排序

  前面介绍排序的时候提到了时间复杂度为 O(n2)的冒泡排序、插入排序、选择排序,还有时间复杂度为 O(nlogn)的归并排序、快速排序。现在说的堆排序时间复杂度也是 O(nlogn),并且是原地排序。

  堆排序分解为两大步骤:建堆和排序。

(1)建堆

   将原数组原地建堆,可以从后向前进行数据处理,每个数据都是从上往下进行堆化。来看下分解图:

          

  先看下代码,再具体分析实现逻辑:

private static void buildHeap(int[] a, int n) {
for (int i = n/2; i >= 1; --i) { heapify(a, n, i); } } private static 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; } }

  在代码中,对下标从 n/2 到 1 的数据进行堆化,因为对于完全二叉树来说,n/2+1 到 n 的节点都是叶子节点,所以不需要进行堆化。

  建堆的时间复杂度是 O(n)。

(2)排序

  根据上面的代码,建堆之后,数组中的数据已经是按照大顶堆的特性来组织的,数组中的第一个元素就是堆顶,也就是最大的元素。把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。原来下标为 n 的位置元素放在了堆顶位置,再通过堆化的方法,将剩下 n-1 个元素重新构建成堆,然后重复这个过程,有点类似元删除堆顶的过程,直到最后堆中只剩下标为 1 的元素,整个排序就完成了。

      

  根据这个过程写出代码:

// n表示数据的个数,数组a中的数据从下标1到n的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n);
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}

(3)时间复杂度分析

  堆排序分为建堆和排序两个过程,建堆的时间复杂度是 O(n),排序的时间复杂度是 O(nlogn),所以总体来说堆排序的时间复杂度是 O(nlogn)。

  另外堆排序不是稳定的排序,因为存在将堆顶元素和末尾元素交换,可能破坏相同数值的元素原来的位置。

3、解答开篇

  在实际开发中,为什么快速排序比堆排序性能好?主要有两点原因:

(1)快排比堆排数据访问方式要友好

  快速排序是分区局部访问,但是堆排是跳着访问的,所以对CPU缓存不友好。

(2)对于同样的数据,堆排数据交换次数比快排多

  对于快排,数据有序度越高,交换次数越少,但是堆排在建堆过程中会打乱原先数组的顺序。

标签:系列,堆化,int,复杂度,堆排序,程序员,基本功,排序,节点
来源: https://www.cnblogs.com/jing-yi/p/15902199.html