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

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

什么是堆

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

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

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

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

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

如何实现一个堆

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

存储一个堆

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

image

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

往堆中插入一个元素

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

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

插入1

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

插入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;
        }
    }

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

删除堆顶元素

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

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

删除1

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

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

删除2

代码实现如下:

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

如何基于堆实现排序

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

建堆

建堆有两种思路:

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

这里介绍第二种思路。

建堆

代码实现如下:

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成正比。

推导1

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

推导2

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

推导3

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

推导4

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

排序

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

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

排序

代码实现如下:

// 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;
    }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354