您的位置:首页 > 理论基础 > 数据结构算法

数据结构与算法——堆与堆排序

2019-03-17 13:57 225 查看

堆(heap)是计算机科学中一类特殊的数据结构的统称

堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:

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

下面解释一下这两点性质:

第一点,堆必须是一个完全二叉树。完全二叉树我们之前学习过,完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。

第二点,堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。实际上,采用递归的定义方式可以说成,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。

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

其中第1个和第2个是大顶堆,第3个是小顶堆,第四个不是堆(非完全二叉树)。除此之外我们发现,与二叉排序树一样,对于同一组数据,我们可以构建多种不同形态的堆(但是树的高度是相同的)。

堆的实现

我们之前在学习完全二叉树的时候,讲过完全二叉树比较适合用数字来存储。用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯的通过数组的下标,就可以找到一个节点的左右子节点和父节点。下面给出一个用数组存储堆的例子:

 从图中可以看到,数组中下标为i的节点的左子节点,就是下标为i*2的节点,右子节点就是下标为2*i+1的节点,父节点就是下标为i/2的节点。

堆的插入操作

往堆中插入一个元素后,我们需要继续满足堆的两个特性。如果我们把新插入的元素直接放到堆的最后,那么有可能就不符合堆的第二个性质了。于是我们就需要进行调整,让其重新满足堆的特性,这个过程我们叫作——堆化(heapify)

堆化实际上有两种,从下往上和从上往下。先学习从下往上的方法:

堆化非常简单,就是顺着节点所在的路径,向上或者向下,对比,然后交换。这里给出一张堆化(从下往上)的过程分解图。整个过程就是不断与父节点比较,交换的过程,直到父子节点满足堆的第二条性质或者遇到根节点。

下面给出插入操作的实现代码:

[code]const int Heap_Size = 100;
class Heap
{
private:
int a[Heap_Size];
int count;
public:
Heap() { count = 0; }
void insert(int data);
}

void Heap::insert(int data)
{
if (count >= Heap_Size) return;
a[++count] = data;
int i = count;
while (i/2 > 0 && a[i] > a[i/2])
{
swap(a[i], a[i/2]);
i = i/2;
}
}

堆的删除操作(删除堆顶元素)

从堆的定义的第二条中,任何节点的值都大于等于(或者小于)等于子树节点的值,我们能够知道,堆顶元素存储的就是堆中数据的最大值或者最小值。如果我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代的选取第二大节点,填补父节点的空缺,依此类推,直到叶子节点。这里给出删除操作的示意图:

但是这种从上往下不断填补空洞的调整方法,最后堆化除了的堆有可能并不满足完全二叉树的特性。实际上,我们可以改变一下思路,先把最后一个节点放到堆顶,然后利用同样的父子节点对比的方法。不过这一次我们不再是填补,而是对于不满足父子节点大小关系的进行相互交换,直到父子节点之间的关系满足大小关系为止。这就是从上往下的堆化方法。

下面给出删除操作的实现代码:

[code]void removeMax()
{
if (count == 0) return;
a[1] = a[count];
--count;
heapify(a, count, 1);
}

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;    // 存在左子节点,且a[i]小于其左子节点
if (i * 2 + 1 <= n && a[maxPos] < a[i*2+1]) maxPos = i * 2 + 1;    // 存在右子节点,且a[i]小于其右子节点
if (maxPos == i) break;    // 若以上两点均不满足,无需堆化,直接退出
swap(a[i], a[maxPos]);
i = maxPos;                // 继续向下堆化
}
}

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

堆排序

我们之前学习过很多排序算法,其中有时间复杂度为O(n²)的冒泡排序、插入排序、选择排序,有时间复杂度是O(nlogn)的归并排序、快速排序,还有线性排序。我们借助堆这种数据结构实现的排序算法,称为堆排序。这种排序方法的时间复杂度非常稳定,是O(nlogn),并且它还是原地排序。

我们把堆排序的过程大致分解成两个大的步骤:建堆和排序。

  • 建堆

首先将数组原地建成一个堆,所谓“原地”就是不借助另一个数组,在原数组上操作。建堆的过程,有两种思路。

第一种是借助之前堆的插入操作的思想。尽管数组中包含n个数据,但是我们可以假设,起初堆中只包含一个数据,就是下标为1的数据。然后,我们调用前面所讲的插入操作,将下标从2到n的数据依次插入到堆中。这样就将包含n个数据的数组,组织成了堆。

第二种的实现思路跟第一种截然相反,第一种建堆思路的处理过程是从前往后处理数组数据,并且每个数据插入堆中时,都是从下往上堆化。而第二种实现思路,是从后往前处理数组,并且每个数据都是从上往下堆化。

下面给出第二种建堆实现思路的分解步骤图:

下面给出第二种建堆的实现代码:

[code]void buildHeap(int a[], int n)
{
for (int i = n/2; i >= 1; --i)
{
heapify(a, n, i);
}
}

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;    // 存在左子节点,且a[i]小于其左子节点
if (i * 2 + 1 <= n && a[maxPos] < a[i*2+1]) maxPos = i * 2 + 1;    // 存在右子节点,且a[i]小于其右子节点
if (maxPos == i) break;    // 若以上两点均不满足,无需堆化,直接退出
swap(a[i], a[maxPos]);
i = maxPos;                // 继续向下堆化
}
}

我们发现,我们在堆化的过程中是从下标为n/2开始到1的数据进行堆化,下标是n/2+1到n的节点叶子节点,我们不需要堆化 。实际上,对于完全二叉树来说,下标从n/2+1到n的节点都是叶子节点。

我们现在来分析建堆操作的时间复杂度,每个节点堆化的时间复杂度是O(logn),那么n/2个节点堆化的总时间复杂度是不是就是O(nlogn)呢?这个答案虽然也没错,但是这个值还是不够精确。实际上,堆排序的建堆过程的时间复杂度是O(n)。现在来推导一下:

因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换节点的个数,跟这个节点的高度k成正比。我们把每一层的节点个数和对应的高度画出来,根据下图,我们只需要将每个节点的高度求和,得出的就是构建堆的时间复杂度。

我们将每个非叶子节点的高度求和,就是下面的这个公式:

[code]S1=(2^0) * h + (2^1) * (h-1) + (2^2) * (h-2) + (2^3) * (h-3) + ... + (2^k) * (h-k) + ... + (2^(h-1)) * 1

 这个公式就是我们经常遇到的等比数列与等差数列乘积的组合数列,求解的方法是:把公式左右都乘以2,就得到另一个公式S2。我们将S2错位对齐,并用S2减去S1,就可以得到S。这里直接给出最终的公式S:

[code]S = -h + 2 + 2^2 + 2^3 + ... + 2^k + ... + 2^(h-1) + 2^h
S = -h + ((2^h) - 2) + 2^h = 2^(h+1) - h - 2

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

  • 排序

建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为n的位置。这个过程有点类似“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为n的元素放到堆顶,然后通过堆化的方法,将剩下的n-1个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是n-1的位置,一直重复这个过程,知道最后堆中只剩下标为1的一个元素,排序工作就完成了。

下面给出堆排序的实现代码:

[code]void sort(int a[], int n)
{
buildHeap(a, n);
int k = n;
while (k > 1)
{
swap(a[1], a[k]);
--k;
heapify(a, k, 1);
}
}

 现在,再来分析一下堆排序的时间复杂度、空间复杂度以及稳定性:

  • 整个堆排序的过程,都只需要极个别的临时存储空间,所以堆排序是原地排序算法。
  • 堆排序包括建堆和排序两个操作,建堆的时间复杂度是O(n),排序过程的时间复杂度是O(nlogn),所以,堆排序整体的时间复杂度是O(nlogn)。
  • 堆排序不是稳定的排序算法,因为在排序的过程中,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。

最后需要注意的是,以上关于堆排序的所有操作都是假设数组当中的数据是从下标为1开始存储的,如果数组数据是由0开始存储的话,处理的思路不变,只需将计算子节点和父节点的下标公式即可。如果节点的下标是i,那左子节点的下标就是2*i+1,右子节点的下标就是2*i+2,父节点的下标就是(i-1)/2。

堆排序与快速排序

首先这里思考一个问题,在实际开发中,为什么快速排序要比堆排序性能好?

  • 第一点,堆排序数据访问的方式没有快速排序友好。

对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面的这个例子,对堆顶节点进行堆化,会依次访问数组下标是1,2,4,8的元素,而不是像快速排序那样,局部依次访问,所以,这样对CPU缓存是不友好的。

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

我们在学习快速排序中,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(移动)。快速排序数据交换的次数不会比逆序度多。但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: