程序员基本功系列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)对于同样的数据,堆排数据交换次数比快排多

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

posted @ 2022-02-16 20:23  jingyi_up  阅读(130)  评论(0编辑  收藏  举报