《数据结构与算法之美》23——堆和堆排序

今天我们来学习堆和堆排序。

什么是堆

堆是一种特殊的树,满足以下两点要求:

  • 堆是一个完全二叉树。
  • 堆中每个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。

通过要求二可知,堆有两种类型,大顶堆和小顶堆:

  • 对于每个节点的值都大于等于子树中每个节点值的堆,叫作“大顶堆”。
  • 对于每个节点的值都小于等于子树中每个节点值的堆,叫作“小顶堆”。

image

上图中,第1、2个是大顶堆,第3个是小顶堆,第4个不是堆。

如何实现一个堆

首先要知道堆支持哪些操作,以及如何存储。

存储一个堆

完全二叉树比较适合用数组来存储。通过下标即可找到一个节点的左右子节点和父节点。

image

堆的操作包括两个:往堆中插入一个元素,删除堆顶元素。

往堆中插入一个元素

往堆中插入一个元素的过程是,把新插入的元素放到堆的最后,通过调整,让其重新满足堆的特性,这个过程叫作堆化(heapify)。

堆化有两种,从下往上从上往下。这里介绍从下往上的堆化方法。

image

堆化非常简单,就是顺着节点所在的路径,向上或者向下对比,然后交换。

image

代码实现如下:

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);
            i = i / 2;
        }
    }

    private void Swap(int[] a, int x, int y)
    {
        int temp = a[x];
        a[x] = a[y];
        a[y] = temp;
    }
}

删除堆顶元素

从堆的定义的第二条中,任何节点的值都大于等于(或小于等于)子树节点的值,可以发现,堆顶元素存储的就是堆中数据的最大值或最小值。

假设堆是大顶堆,删除堆顶元素之后,就要把第二大的元素放到堆顶,如下图

image

但是这种方法有点问题,就是最后堆化的堆并不满足完全二叉树的特性。

通过从上往下的堆化方法可以解决这个问题,把最后一个节点放到堆顶,然后利用同样的父子节点对比方法,对于不满足父子节点大小关系的,互换两个节点,并且重复这个过程,直到父子节点之间满足大小关系为止。

image

代码实现如下:

public void RemoveMax()
{
    if (count == 0) return;

    // 把最右侧子树的叶子节点放到堆顶
    a[1] = a[count];

    // 去掉最后一个元素
    count--;

    // 堆化
    Heapify(a, n, 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[2 * i + 1]) maxPos = i * 2 + 1;

        // 如果最大节点为当前节点,跳出
        if (maxPos == i) break;
        // 否则交换,并进入下一次循环
        Swap(a, i, maxPos);
        i = maxPos;
    }
}

时间复杂度

一个包含n个节点的完全二叉树,树的高度不会超过\(\log_2 n\)。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,因此时间复杂度是O(logn)。

如何基于堆实现排序

堆排序的过程大致分解为两大步骤:建堆和排序。

建堆

建堆有两种思路:

  • 在堆中插入一个元素。从前往后处理数组数据,并且每个数据插入堆中时,都是从下往上堆化。
  • 与第一种截然相反,从后往前处理数组,并且每个数据都是从上往下堆化。

这里介绍第二种思路。

image

代码实现如下:

private void BuildHeap(int[] a, int n)
{
    // 从下标n/2到1进行堆化
    // 因为叶子节点不需要堆化,而堆是完全二叉树,即当前节点i的左节点是2*i,右节点是2*i+1,即最后一个父节点只能是n/2
    for (int i = n / 2; i >= 1; i++)
    {
        Heapify(a, n, i);
    }
}

// 自上往下堆化
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[2 * i + 1]) maxPos = i * 2 + 1;

        // 如果最大节点为当前节点,跳出
        if (maxPos == i) break;
        // 否则交换,并进入下一次循环
        Swap(a, i, maxPos);
        i = maxPos;
    }
}

这段代码中,我们对下标从\(\frac{n}{2}\)开始到1的数据进行堆化,下标从\(\frac{n}{2}+1\)到n的节点都是叶子节点。

时间复杂度

每个节点堆化的时间复杂度是O(logn),那\(\frac{n}{2}+1\)个节点的堆化的总时间复杂度是O(nlogn)。

推导过程

堆化节点从倒数第二层开始。堆化过程中,需要比较和交换的节点个数与这个节点的高度k成正比。

image

将每个非叶子节点的高度求和,得到下面的公式S1:

image

这个公式的求解稍微有点技巧,我们把公式左右都乘以2,得到另一个公式S2,再将S2减去S1,就可以得到S了。

image

S的中间部分是一个等比数列,用等比数列的求和公式来计算,最终的结果就是下图的样子。

image

因为\(h=\log_2 n\),代入公式S,就得到S=O(n),所以建堆的时间复杂度是O(n)。

排序

建堆后,数组中的数据是大顶堆。把堆顶元素,即最大元素,跟最后一个元素交换,那最大元素就放到了下标为n的位置。

这个过程有点类似上面的“删除堆顶元素”的操作,当堆顶元素移除之后,把下标n的元素放堆顶,然后再通过堆化的方法,将剩下的n-1个元素重新构建成堆。一直重复这个过程,直到最后堆中只剩下下标为1的元素,排序就完成了。

image

代码实现如下:

// n表示数据的个数,数组a中的数据从下标1到n的位置。
public void Sort(int[] a, int n)
{
    BuildHeap(a, n);

    int k = n;
        while (k >= 1)
    {
        Swap(a, 1, k);
        --k;
        Heapify(a, k, 1);
    }
}

时间复杂度、空间复杂度、稳定性

堆排序包括建堆和排序两个操作,建堆过程的时候复杂度是O(n),排序过程的时间复杂度是O(nlogn),所以整体排序的时间复杂度是O(nlogn)。

整个堆排序的过程,只需要极个别临时存储空间,所以堆排序是原地排序算法。

堆排序不是稳定的排序算法,因为在排序过程,存在将堆最后一个节点跟堆顶节点互换的操作,所有就有可能改变值相同数据的原始相对顺序。

在实际开发中,为什么快速排序要比堆排序性能好

1.堆排序数据访问的方式没有快速排序友好

快速排序,数据是顺序访问的。堆排序,数据是跳着访问的。对CPU缓存不友好。

2.对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。

快速排序数据交换的次数不会比逆序度多。

堆排序的建堆过程会打造数据的原有顺序,降低有序度,交换次数更多。

整体代码:

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);
            i = i / 2;
        }
    }

    public void RemoveMax()
    {
        if (count == 0) return;

        // 把最右侧子树的叶子节点放到堆顶
        a[1] = a[count];

        // 去掉最后一个元素
        count--;

        // 堆化
        Heapify(a, n, 1);
    }



    private void BuildHeap(int[] a, int n)
    {
        // 从下标n/2到1进行堆化
        // 因为叶子节点不需要堆化,而堆是完全二叉树,即当前节点i的左节点是2*i,右节点是2*i+1,即最后一个父节点只能是n/2
        for (int i = n / 2; i >= 1; i++)
        {
            Heapify(a, n, i);
        }
    }

    // n表示数据的个数,数组a中的数据从下标1到n的位置。
    public void Sort(int[] a, int n)
    {
        BuildHeap(a, n);

        int k = n;
        while (k >= 1)
        {
            Swap(a, 1, k);
            --k;
            Heapify(a, k, 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[2 * i + 1]) maxPos = i * 2 + 1;

            // 如果最大节点为当前节点,跳出
            if (maxPos == i) break;
            // 否则交换,并进入下一次循环
            Swap(a, i, maxPos);
            i = maxPos;
        }
    }

    private void Swap(int[] a, int x, int y)
    {
        int temp = a[x];
        a[x] = a[y];
        a[y] = temp;
    }
}
posted @ 2020-07-14 09:53  大杂草  阅读(268)  评论(0编辑  收藏  举报