教材上很详细,网上内容也不少,但感觉不够直观、简练、丰富。下面按照自己方式总结下。
提纲:
1)算法描述
2)代码
3)“三围”以及证明(复杂度、效率、稳定性等分析)
4)算法直接应用
5)算法原理应用
6)举例
一、算法描述:
堆概念(数据结构):堆是一颗完全树,同时满足每个节点均大于或小于它的子节点,这样的数据结构被称为最大堆或者最小堆。很多博客里面说是一个完全二叉树,实际上三叉、四叉也是可以的,只不过对数运算曲线变化很快,2叉就足够了,一般只使用二叉就足够了,而且这样编写算法也容易很多。这个很好理解,10亿约等于2的30次方,即10亿个数之通过30次就可以求解,因此就没必要搞三叉了。常见的堆有二叉堆、斐波那契堆等。
堆的特点:这种结构处于一种半排序状态,它的存取效率从整体上讲介于有序和无序之间,具体可以参考本特利的《算法珠玑》中堆章节关于有序序列、无序序列和堆在操作上的复杂的分析。当对象处于动态时,是种非常有优势的数据结构。存取的时间复杂为log(a,n),其中a为完全树的度, 理解这种特点对于它的应用很有帮助。
二叉堆存储以及规律:通常二叉堆以数组存储,且父子节点的下标存在如下关系:父节点(下标为:i)的左孩子节点下标为:2i,右孩子下标为:2i+1,另外堆顶元素的下标为 1,层为 log(2,n)。注:下标以1开始计算。
堆排序思路:对于一个堆(以下皆以二叉最小堆说明),去掉它的堆顶元素,然后以最末端元素移动到堆顶位置,然后进行调整,使之再次成为最小堆。如此迭代,直到没有剩余的元素,依次取出来的顺序就是实现排序的过程。表达成伪代码即:
建堆;
循环(条件:堆不为空){
取出堆顶元素;
将最后一个元素移动到堆顶位置;
调整使之再次成为堆;
}
建堆思路:从最后一个非叶子节点开始,按照从下到上的顺序进行迭代,让它(下标记为x)同其孩子节点(下标:2x、2x+1)比较,如果满足小于任何一个孩子节点,则说明这个子树是符合堆规则的;否则把其它同其最小的子节点交换。由于交换后,以这个节点(x)会破坏原来孩子的堆特性,因此这里有一个子迭代,让它继续上面的行为,直到找到一个合适的位置落脚。如此,直到根节点,就可以保证整颗完全树具备堆的性质。具体见图片,这里面描述的是大根堆的建堆过程,小根堆是一样的。就偷个懒吧
【?】:
1> 仔细观察和思考后,我们可以发现其实建堆和堆调整可以提取一个公共的方法来。就是在子树具备堆特性的条件下,可以使用相同的方法进行调整。(1个根节点可以认为是一颗特殊的堆,即使最大堆也是最小堆。)
2> 思考下为什么采用自底向上的方法进行迭代,反过来行不行??? 尝试下就会发现这么做的好处。
3> 思考下什么情况下,会发生子迭代,即循环跟其孩子节点、孙子节点等所有后代进行比较的情况。
二、代码(自己开发的,基于严蔚敏老师的《数据结构》思路的算法,已经编译并测试通过。)
//program name : heap sort
//author : Dam
//email : [email protected]
//discription : This is a sort that fits for find max or min key from huge data;
// The tree mentioned before is binary tree.And it is not stabled.
// step 1:build a heap
// step 2:delete the root node;
// step 3:rebuild the heap by using the left nodes
// step 4:loop step 2 and step 3,until there is no node left.
//space & time:
// space: S(n)= O(1)
// time: T(n)= nlog(2,n)
#include
#include
#define FALSE 0
#define TRUE 1
#define NUMMAX 1000000
typedef struct hs_data
{
unsigned int key;
char value[30];
}ListType, *pListType;
int print_set(ListType lt[],unsigned int n);
int hs_sift(ListType r[],int k,int n);
int getNTopValue(ListType lt[],int n);//Sub HeapSort - unprogramed.
int HeapSort(ListType r[], int n);
// heap sort in sequent order
int hs_sift(ListType r[],int k,int n)
{
int i=k,j=2*i+1,finished= FALSE;
unsigned int x=r[k].key;
ListType t = r[k];
while((j r[j+1].key))
j++;
if (x <= r[j].key)
finished= TRUE;
else
{
r[i]= r[j];
i= j;
j= 2*i+1;
}
}
r[i]= t;
return 0;
}
// This algs needs one place;
int HeapSort(ListType r[], int n)
{
int i;
//build the heap
//loop every unleaf node,and compare it with its children,grand...children.
for(i=(n-1)/2; i >= 0; i--)
hs_sift(r, i, n);
//printf("after build deap!\n");
//print_set(r,n);
//delete the root node from heap,then rebuild the heap,until there no node left
for(i=n-1; i > 0; i--)
{
//replace the root node using the last node,then resort it
ListType t;
t = r[0];
r[0]= r[i];
r[i]= t;
hs_sift(r, 0, i);
}
//printf("after deap sort!\n");
//print_set(r,n);
return 0;
}
// print the result in before sorting and after sorting
int print_set(ListType lt[],unsigned int n)
{
printf("=====================================\n");
printf("start print....\n");
int i=0;
while(i < n )
{
printf("%u\n", lt[i].key);
i++;
}
printf("print end ....\n");
printf("=====================================\n");
return 0;
}
int main(void)
{
ListType big_set[NUMMAX+1];
int i;
//init the rander with different seeds;
srand(time(NULL));
for( i=0; i < NUMMAX; i++)
{
ListType t;
memset(&t,'\0', sizeof(ListType));
t.key=rand()%(NUMMAX);
//t.key=(unsigned int)(10-i);
strcpy(t.value, "");
big_set[i]= t;
}
printf("before sorting,the set is :\n");
print_set(big_set, NUMMAX);
HeapSort(big_set, NUMMAX);
printf("after sorting, the set is :\n");
print_set(big_set, NUMMAX);
return 0;
}
【?】:
1> 顺序用大根堆,逆序用小根堆,思考下为什么??
三、三围分析以及证明
时间复杂度:
建堆需要的时间:O(n)
证明:设高度为h,则节点数总和最多为:2^h-1,某一层H最多的节点为:2^(H-1) 从根节点到最后一个非叶子节点,其最坏复杂度为它到最外层的路径长度,即假使每个节点均需要交换。另外这里的单位,是占用时间最主要的一个单位的交换所需时间。
T(n) =1*(h-1) + 2*(h-2) + 4*(h-3) + ... + 2^(h-3)*(h-(h-2)) + 2^(h-3)*(h-(h-1))
=h(1+2+4+...+ 2^(h-1)) - (1 + 2*2 +4*3 + 8*4 + 16*5 + .. + 2^(h-3) *(h-2) + 2^(h-2)*(h-1) )
前面是个等比数列 其求和公式为:(a1-an*q)/(1-q) (q≠1)
而后面这部分的通项公式为: 2^(n-1) * n,
设这部分记为Sn,我们需要找出前n项和的规律来。
我们对Sn*2 : 2*1 + 4*2 + 8*3 + 16*4 + ... + 2^(h-2) *(h-2) + 2^(h-1)*(h-1)
然后 Sn*2 -Sn = Sn = 2^(h-1)*(h-1) - (1 + 2 + 4 + 8 + 16 + ... + 2^(h-2) ) ----后面这个又是一个等比数列,代入公式求解
继续化简Sn:
Sn = 2^(h-1)*(h-1) - 2^(h-1) = 2^(h-1)*(h-2)
带入Sn,化简T(n):
T(n) = 2^h * h - 2^(h-1)*(h-2)
= 2^(h-1) * (h+2)
= 2^h * (h+2)/2
≈ n * log(2,n)/2
忽略常数项即:log(2,n) * n
取堆顶元素并调整需要的时间:O(nlog(2,n))
n个元素需要重复n次这样的动作,而每次这样的需要的时间与此时数的高度相关,粗略计算时可以认为高度不变
这样时间复杂度为: n * log(2,n)
最坏情况:同平均复杂度
最好情况:O(1)
空间复杂度:
O(1),用来交换的临时空间。很容易证明:建堆过程不需要额外的空间,之需要交换用的一个单位的临时空间;取堆顶元素,并调整的过程,仔细观察过程并未真正把它取出来,而是跟堆尾元素交换,并调整堆尾下标即可。
稳定性:
把稳定性是由于其下标规则跟父子关系不一致导致的,父子比较并交换的情况下可以保证其稳定性,但是相同关键字的2个元素有可能并不是同一个父节点,因此并保证不了其稳定性。
【?】:
1> 解释下稳定性,以前也不明白,担心其他人也有不明白的。所谓稳定性是指如果2个元素的关键字相同,排序过程是否会打乱其顺序。这对于基于多个关键字排序是很有用的,如果不稳定,就不能直接应用于多个关键字的排序。自己想想为什么??
2> 整个排序过程中,主要时间消耗在交换上,因此时间复杂度体现的是单位交换时间的次数。
四、算法直接应用
对较大的序列排序,时间复杂度同快速排序、归并排序,特长是使用很少空间,适合相对有序的序列排序。一般情况下,快速排序更好些,c语言的qsort库函数使用的就是快速排序。
五、算法原理应用
优先级队列:与普通队列的先进先出不同,这种队列插入和删除时取决于元素的优先级 ,这是一种非常有用的队列。操作系统就是使用这样一种数据结构来表示一组任务。而用堆实现同有序序列和无序序列相比,在插入和删除(或者叫提取)的效率上比较折中。
优先队列有3个常用的函数:
(1) 取最大(小)优先级的元素,其时间复杂度为 O(1)
(2) 插入新的元素,过程相当于插入堆尾,然后进行堆调整。只是这里不需要重新做一遍建堆过程,而是从新插入的元素沿着它的父节点一直到根节点这 log(n+1)个节点。因此其时间复杂度为:
T(n) = log(2,n+1) + (log(2,n+1)-1) + ... +2 + 1
= (1+log(2,n+1))* log(2,n+1)/2
= (log(2,n+1)+1/2)^2 / 2 -1/8
≈ log(2,n+1)^2 /2
粗略计算的可以认为是:log(2,n)
(3)出去最大元素,并调整,这个时间复杂度就是 log(2,n)
六、举例
1. 请给出一个时间为O(nlgk),用来将k个已排序链表合并为一个排序链表的算法。此处的n为所有输入链表中元素的总数。
【解析】使用排序的归并方法的话,所用时间为: 2kn + (2kn+n) +...+((k-1)n+n)
编程思路:使用最小堆,有个朋友写得非常详细,就不重复了。在这里我们又一次体会到在动态的序列中堆的优势来。
假设k个链表都是非降序排列的。
(1)取k个元素建立最小堆,这k个元素分别是k个链表的第一个元素。建堆的时间复杂度O(k)。
(2)堆顶元素就是k个链表中最小的那个元素,取出它。时间复杂度O(1)。--只要在最小堆中保证每个序列都有一个元素,就可以保证最小堆取出来的一定是当前的最小值。
(3)若堆顶元素所在链表不为空,则取下一个元素放到堆顶位置,这可能破坏了最大堆性质,所以进行堆调整。堆调整时间复杂度O(lgk)。若为空,则此子链表已经被合并完毕,则删除最小堆的堆顶元素,此时最小堆的heapSize减小了1 。删除指定元素时间复杂度O(lgk)。
(4)重复步骤(2)~(3)n-k次。总的时间复杂度是O(k)+O(nlgk)即O(nlgk)。 http://hi.baidu.com/tuangougou/item/872f45d42f69ad3838f6f754
2. 一个文件中包含了1亿个随机整数,如何快速的找到最大(小)的100万个数字?(时间复杂度:O(n lg k))
分析:为了更深刻理解这个题目,我们使用3种方法解决这个问题:
1) 直接法:先找出最大,然后第二大,半个选择排序,复杂度:10000* n
2) 堆排序原理法: 先构建最大堆:时间复杂度为n ,然后进行10000次取堆顶元素,并调整 10000*log(2,n) 那么 总时间为 n+10000*log(2,n)=n+10000*10000 = 2n
3) 快速排序原理法:快速排序的方法是每次用第1个元素对序列一分为2 ,然后分别对2部分进行递归快速查 找。 如果只取前100w个,则如果判断前面部分总数大于100w个,则后面那部分不用理会。这样递归,如果 前面那部分总数最大接近100w的时候,就可以进行一次全排序。那么时间复杂度为: log(2,100w)*100w + n+ n/2 + n/4 …+ n/10000 接近2n。