• 《数据结构与算法之美》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;
        }
    }
    
  • 相关阅读:
    Excel中的Vlookup函数用法
    C# WinForm 在窗口菜单上显示已打开窗体的标题及其窗体的排列
    C#使用Windows注册表编辑
    World Creater
    AtService.exe 占用CPU资源较高的解决办法
    几段SQL Server语句和存储过程(转载)
    代码转换工具
    ReSharper 4.5 Feature Map
    DXperienceXaf9.2.6 Personal Computer SN
    RegexBuddy v3.1.1 破解版
  • 原文地址:https://www.cnblogs.com/liang24/p/13297454.html
Copyright © 2020-2023  润新知