堆排序(HeapSort)

本文深入探讨了二叉堆的构建与维护方法,并详细解释了堆排序算法的工作原理及其实现过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

(二叉)堆

虽然要搞清楚一组数据完整的大小关系很费时间,但如果只想弄明白一部分大小关系的话就能在很短的时间内确定,
这就是堆序的思想:
把数组按下标排成一棵完全二叉树,该树满足的“一部分大小关系”就是所有节点是它所在子树的最大值(最大堆性质)


下标关系

对于二叉堆父节点下标和其两个孩子节点下标的关系很明显:
#define PARENT(i) (i >> 1)  ///parent = i / 2 向下取整
#define LEFT_CHILD(i) (i << 1)  ///leftChild = i * 2
#define RIGHT_CHILD(i) ((i << 1) + 1)   ///rightChild = i * 2 + 1
要证明这一关系需要引进层(layer)的概念:节点所处层数为该节点的深度,而节点的深度又是该节点到根节点的最短路径长度(没记错的话),
唉,反正定义是为了帮助理解,能起作用就行。所以把上图中的完全二叉树分层得到:

这样一来就可以把任一下标映射为 第 i  层 的第  j  个:  k  = 2^i + j   (i和j均从0开始)
由于每层到下一层都会增加一倍元素(空位也算上),所以第 i 层第 j 个元素的左孩子在 i + 1层的第2j 位置,
所以LEFT_CHILD(K)  = 2^(i + 1) + 2j = 2k,这一关系得到自然也得到右孩子和父亲的关系。
用这一过程也可以求出下标不是从1开始(java写的堆一般从0开始)的下标关系,也可以求d叉堆的下标关系。

优化"堆化"函数

算法导论上的堆化函数是递归函数:对违反堆序的节点i不断下降直到它不再违反堆序。
但该递归过程可以优化为循环过程,再进一步分析,我们发现整个堆化过程都是在帮第i个节点找正确的安放位置,
何不把它保存在临时变量中,直到找到正确位置再安放上去呢,这个技巧就和插入排序中把待插入元素保存在临时变量中一致:
static int* heap;   ///组成堆的数组,用全局变量保存下来,免得每次都得传递参数
static int heapSize;    ///记录堆的当前的大小

/****************************************
    函数:对节点i进行堆化
    费用:最坏情况O(h) h为节点i的高度
****************************************/
void heapify(int i)
{
    int tmp = heap[i];  ///先用临时变量保存待堆化元素
    for(int maxChild = LEFT_CHILD(i); maxChild <= heapSize; i = maxChild, maxChild = LEFT_CHILD(i))
    {
        ///找出最大的孩子
        if(maxChild < heapSize && heap[maxChild + 1] > heap[maxChild])
            maxChild++;     ///如果有右孩子并且右孩子大于左孩子
        ///判断tmp在当前位置有没有违反堆序
        if(heap[maxChild] > tmp)
            heap[i] = heap[maxChild];
        else break;
    }
    heap[i] = tmp;  ///最后直接安放到正确位置
}
当然还可以把第一次测试单独提出来,不过这一优化微乎其微,因为绝大部分元素是要下降的(后面会讲下降次数期望),而且难写难记就算了。

建堆花费

首先建堆的过程是不断对高度h的节点建立子堆,高度h从1升到lgn也就是根节点完成建堆。因为高度为h-1的节点为根的堆是高度为h的节点为根的堆的子堆。
最后一个高度为1的节点是最后一个高度为0的节点的父亲,所以建堆循环顺序可以为 PARENT(heapSize) 到 1.
/*****************************************
    函数:堆排序建堆部分
    参数:a为待排序数组,n为数组元素个数
    费用:Ω(n) O(3n)
*****************************************/
void heapSort(int* a, int n)
{
    heap = a - 1;   ///这样heap[1] = a[0],使得下标从1开始
    heapSize = n;   ///设定堆的大小
    ///建堆
    for(int i = PARENT(heapSize); i > 0; i--)
        heapify(i);
}
该过程的最好情况的时间复杂度就是数据原本就满足堆序,没有数据下降,这样只发生n - 1次比较(树的边数),所以有下界Ω(n)
而最坏情况下,所有节点都会下降h次(h为节点自身高度),而下降一次需要两次比较,一次赋值,得到级数:

所以得到O(3n)。
这一结果可以推广到d叉堆:Ω(n),O((1 + 2 / (d - 1)) * n),最坏情况下,随着d增大时间复杂度的常系数越接近1,说明d越大越能更快建堆。
这也很容易理解:叉数变大,所确定的大小关系越稀疏,花的时间也就越少了,比如对n个元素建立n - 1叉堆,就相当于找出最大数而已,这时的常系数便是1.

建堆“下降次数”的期望

这里的下降次数指的是所有节点降低的高度总和,也就是下面 cnt 的大小的期望
int cnt;    ///下降次数

void heapify(int i)
{
    int tmp = heap[i];
    for(int maxChild = LEFT_CHILD(i); maxChild <= heapSize; i = maxChild, maxChild = LEFT_CHILD(i))
    {
        if(maxChild < heapSize && heap[maxChild + 1] > heap[maxChild])
            maxChild++;
        if(heap[maxChild] > tmp)
        {
            heap[i] = heap[maxChild];
            cnt++;  ///下降在这里发生
        }
        else break;
    }
    heap[i] = tmp;
}

void heapSort(int* a, int n)
{
    heap = a - 1; 
    heapSize = n;
    ///建堆
    cnt = 0;    ///初始化下降次数
    for(int i = PARENT(heapSize); i > 0; i--)
        heapify(i);
    cout << endl << cnt << endl;    ///输出结果
}



对于高度为h的节点不会下降的条件就是它是这个子堆中的最大值,这一概率为1 / (2^(h + 1) - 1) 对于高度为1的节点这一概率才为 1 / 3
所以上面才说绝大部分节点(当然不包括叶子节点)都是要下降的。
接下来是算式了,首先定义Th)表示高度为h的节点下降次数的期望,得到递推关系:
这个递推关系我求不出来,只好用电脑来计算有限和了,不过下面的级数是收敛的误差不大
由于推不出公式就不算d叉堆的系数了。
下面是我的测试结果,输入数据为随机数组:
看来计算还是相当成功的。

完整的堆排序代码

因为堆序的性质使得最大值总是根节点,堆排序就是利用这一点,不断从堆中选出最大值和堆尾元素交换,再让堆的大小减一,再从根节点恢复堆序,
以继续这一过程。这一部分的原理和选择排序一致,不过是利用了堆这一数据结构而已。下面是完整代码:
#define PARENT(i) (i >> 1)  ///parent = i / 2
#define LEFT_CHILD(i) (i << 1)  ///leftChild = i * 2

static int* heap;   ///组成堆的数组,用全局变量保存下来,免得每次都得传递参数
static int heapSize;    ///记录堆的当前的大小

/****************************************
    函数:对节点i进行堆化
    费用:最坏情况O(h) h为节点i的高度
****************************************/
void heapify(int i)
{
    int tmp = heap[i];  ///先用临时变量保存待堆化元素
    for(int maxChild = LEFT_CHILD(i); maxChild <= heapSize; i = maxChild, maxChild = LEFT_CHILD(i))
    {
        ///找出最大的孩子
        if(maxChild < heapSize && heap[maxChild + 1] > heap[maxChild])
            maxChild++;     ///如果有右孩子并且右孩子大于左孩子
        ///判断tmp在当前位置有没有违反堆序
        if(heap[maxChild] > tmp)
            heap[i] = heap[maxChild];
        else break;
    }
    heap[i] = tmp;  ///最后直接安放到正确位置
}

/*****************************************
    函数:堆排序
    参数:a为待排序数组,n为数组元素个数
    费用:nlgn
*****************************************/
void heapSort(int* a, int n)
{
    heap = a - 1;   ///这样heap[1] = a[0],使得下标从1开始
    heapSize = n;   ///设定堆的大小
    ///建堆
    for(int i = PARENT(heapSize); i > 0; i--)
        heapify(i);
    ///直到堆中只剩下1个元素
    while(heapSize > 1)
    {
        ///交换根节点和堆尾元素
        int tmp = heap[1];
        heap[1] = heap[heapSize];
        heap[heapSize] = tmp;
        ///堆大小减一
        heapSize--;
        ///恢复堆序
        heapify(1);
    }
}
这里的排序部分的花费为nlgn,面对这种堆的“删除”操作,堆的叉数越大最坏情况就越费时,但平均情况下堆的叉数越大下降次数的期望就越小,所以
经验表明4叉堆是一个很不错的折中方案。

后记

如果内容有误或有什么更好的优化方案请在下面评论。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值