(二叉)堆
虽然要搞清楚一组数据完整的大小关系很费时间,但如果只想弄明白一部分大小关系的话就能在很短的时间内确定,
这就是堆序的思想:
把数组按下标排成一棵完全二叉树,该树满足的“一部分大小关系”就是所有节点是它所在子树的最大值(最大堆性质)
下标关系
对于二叉堆父节点下标和其两个孩子节点下标的关系很明显:
#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叉堆是一个很不错的折中方案。
后记
如果内容有误或有什么更好的优化方案请在下面评论。