前言
这篇是关于堆排的小节
堆排序算法
首先说说什么叫堆,以及和BST(二叉查找树,二叉排序树),AVL(平衡二叉树)的区别。
堆在内存中很常见,一般内存中分配都是以堆栈分配的(这里原谅渣渣我对jvm虚拟机还没有足够深的认识,这里不敢瞎说)。堆考的最多的有两种,最大堆和最小堆。简单来说:
- 最大堆:树的父亲节点比它的子结点大,即Tree[i]>=Tree[i2]&&Tree[i]>=Tree[i2+1]
- 最小堆:树的父节点比它的子结点小,即Tree[i]>=Tree[i2]&&Tree[i]>=Tree[i2+1]
二叉查找树:二叉查找树定义及算法戳这里
平衡二叉树: 戳这里
算法讲解依旧引用前辈们的博客,贴出几个把堆排序讲解的很好很透彻很容易理解的博客
博客1
博客2
博客3(这个博客之前看过一个版本,代码是错误的,应该是转载的这位大神的,后来删除了,特别恨转载不标明出处的,更可恨的是,你还转载了一个错误的代码给别人,错误还在不太容易发现的地方)
博客4
博客5
好的博客是在太多了,这里记录下来是为了自己以后方便,这篇博文也是加深理解篇,以后忘了堆排序的时候,可以回过头来看看,看着这些性质,可以快速的回忆和总结出来。
首先,拿出堆排序的算法,堆有两种写法,一种是递归的,一中是非递归的。我个人认为递归的好理解一些,所以先拿出递归的算法(这里直接应用博客5中的算法)。
声明:注意根节点是0还是1,如果根节点是1,那么i结点的左结点是2*i,右结点是2*i+1,父节点是i/2。如果根结点是0,那么i结点的左结点是2*i+1,右结点是2*i+2,父结点是(i-1)/2;
//递归解法(最大堆)
void adjust_max_heap_recursive(int *datas,int length,int i)
{
int left,right,largest;
int temp;
left = LEFT(i); //left child
right = RIGHT(i); //right child
//find the largest value among left and rihgt and i.
if(left<=length && datas[left] > datas[i])
largest = left;
else
largest = i;
if(right <= length && datas[right] > datas[largest])
largest = right;
//exchange i and largest
if(largest != i)
{
temp = datas[i];
datas[i] = datas[largest];
datas[largest] = temp;
//recursive call the function,adjust from largest
adjust_max_heap(datas,length,largest);
}
}
用语言描绘下思路:用root表示父结点,left表示左结点,right表示右结点。
建立堆时,要从下往上调整,从右往左遍历所有的非叶子结点。先把left和right调整好,确保left和right已经是最大或者最小堆了,再来调整root。
每次调整某棵树时,从上往下调整,选取父节点,左右子结点中相对较大的与父节点交换。如果父结点就是最大的结点,那么不用交换,该树已经满足最大堆的性质了,如果左结点最大,那么将父结点和子结点交换,交换后,父结点作为left结点的左子树不一定满足最大堆的性质,所以需要同样的思想递归到下一层,而右子树肯定依然是满足最大堆性质的。
//非递归调整最大堆代码
void adjust_max_heap(int *datas,int length,int i)
{
int left,right,largest;
int temp;
while(1)
{
left = LEFT(i); //left child
right = RIGHT(i); //right child
//find the largest value among left and rihgt and i.
if(left <= length && datas[left] > datas[i])
largest = left;
else
largest = i;
if(right <= length && datas[right] > datas[largest])
largest = right;
//exchange i and largest
if(largest != i)
{
temp = datas[i];
datas[i] = datas[largest];
datas[largest] = temp;
i = largest;
continue;
}
else
break;
}
}
//建立堆的代码
void build_max_heap(int *datas,int length)
{
int i;
//build max heap from the last parent node
for(i=length/2;i>0;i--)
adjust_max_heap(datas,length,i);
}
堆排序的思想就是在已经建立好最大堆的基础上,把root结点a[0]和最后一个结点a[n-1]交换,把a[0]到a[n-2]重新调整为最大堆,再把a[0]和a[n-2]交换,再调整堆,再把a[0]和a[n-3]交换…以此类推,最终的出一个有序的数组。
然后是堆的插入,删除,以及稳定性。
这里引用MoreWindows 白话经典算法系列之七 堆与堆排序
/*
每次插入都是将新数据放在数组最后。可以发现从这个新数据的父结点到根结点必然为一个有序的数列,现在的任务是将这个新数据插入到这个有序数据中——这就类似于直接插入排序中将一个数据并入到有序区间中,对照《白话经典算法系列之二 直接插入排序的三种实现》不难写出插入一个新数据时堆的调整代码:
*/
// 新加入i结点 其父结点为(i - 1) / 2
void MinHeapFixup(int a[], int i)
{
int j, temp;
temp = a[i];
j = (i - 1) / 2; //父结点
while (j >= 0)
{
if (a[j] <= temp)
break;
a[i] = a[j]; //把较大的子结点往下移动,替换它的子结点
i = j;
j = (i - 1) / 2;
}
a[i] = temp;
}
更简短的表达为:
void MinHeapFixup(int a[], int i)
{
for (int j = (i - 1) / 2; j >= 0 && a[i] > a[j]; i = j, j = (i - 1) / 2)
Swap(a[i], a[j]);
}
插入时:
//在最小堆中加入新的数据nNum
void MinHeapAddNumber(int a[], int n, int nNum)
{
a[n] = nNum;
MinHeapFixup(a, n);
}
然后是删除,同样应用这篇博文,因为这篇博文实在是太经典了。
按定义,堆中每次都只能删除第0个数据。为了便于重建堆,实际的操作是将最后一个数据的值赋给根结点,然后再从根结点开始进行一次从上向下的调整。调整时先在左右儿子结点中找最小的,如果父结点比这个最小的子结点还小说明不需要调整了,反之将父结点和它交换后再考虑后面的结点。相当于从根结点将一个数据的“下沉”过程。下面给出代码:
// 从i节点开始调整,n为节点总数 从0开始计算 i节点的子节点为 2*i+1, 2*i+2
void MinHeapFixdown(int a[], int i, int n)
{
int j, temp;
temp = a[i];
j = 2 * i + 1;
while (j < n)
{
if (j + 1 < n && a[j + 1] < a[j]) //在左右孩子中找最小的
j++;
if (a[j] >= temp)
break;
a[i] = a[j]; //把较小的子结点往上移动,替换它的父结点
i = j;
j = 2 * i + 1;
}
a[i] = temp;
}
//在最小堆中删除数
void MinHeapDeleteNumber(int a[], int n)
{
Swap(a[0], a[n - 1]);
MinHeapFixdown(a, 0, n - 1);
}
提一点,这里的swap,不能是自定义的函数来交换,因为这样只是在函数里面交换,函数调用完回来,依然没用交换,具体原因可以参考c语言指针。
下面说一下堆排序的运用。堆的应用实在太多了,排序只是一种,最重要的,是堆调整只有log(n)的这种思想。这个后面有时间再好好总结一下。
应用1:
2个有序数组中,前k个小的数。
这里想法有好几个
思路一
用归并排序的思想,把两个数组合并,然后找出前k个,这里不需要合并两个数组,只需要在比较时计数,找出前k个值就行,复杂度为O(k)。
思路二
二分,先找到第一个数组的a[n/2],然后在第二个数组中找到第一个比a[n/2]小的位置,如果两个和加起来大于k,缩小n/2,小于k,放大n/2。这里复杂度是log(n)*log(n);很明显,当K的值很大的时候,适合这种方法。
思路三
用堆。这个题目用堆其实意义不大,因为只有两个数组,很明显看出选取哪个数组中的最小值,但是下面这个扩展的题目,就不得不使用堆了。
应用二:扩展:m个有序数组,前k个小的数。
2个好做,m个呢?
- 用思路一,把m个数组合并?这里就不好合并了,2个数组直接就可以知道选取哪个值。所以,需要先两两合并,最后再回归到两个来解决,这里的复杂度就不好计算了,因为每次合并,数组的个数都增加了,下一次再次合并就不是这个复杂度了,但是一共需要合并(log m)次是确认的。
- 思路二?第一次二分,二分复杂度是logn,复杂度是 (m-1)次二分(log n)(m-1),需要二分log(n)次,所以总复杂度(log(n))(m-1)次方,m过大显然受不了。
最快的方法就是思路三,维护一个k堆,先把每个数组的第一个值放在堆里面,每次取出堆顶,放进与堆顶相邻的元素,下面是例题。
相关题目
这里给出leetcode的题目连接地址K-th Smallest Prime Fraction
AC代码如下
Comparator<Node> comparator = new Comparator<Node>() {
@Override
public int compare(Node o1, Node o2) {
if (o1.node > o2.node)
return 1;
else {
return -1;
}
}
};
class Node {
int x;
int y;
double node;
public Node(int x, int y, double node) {
this.x = x;
this.y = y;
this.node = node;
}
}
public int[] kthSmallestPrimeFraction(int[] A, int K) {
Queue<Node> queue = new PriorityQueue<>(comparator);
int len = A.length;
int[] b = new int[A.length];
for (int i = 0; i < A.length; i++) {
b[i] = A[A.length - i - 1];
}
for (int i = 0; i < A.length; i++) {
queue.add(new Node(i, 0, 1.0 * A[0] / b[i]));
}
Node ans = null;
while (K-- != 0) {
Node node = queue.poll();
ans = node;
if (node.y + 1 < len) {
Node tN = new Node(node.x, node.y + 1, 1.0 * A[node.y+1] / b[node.x]);
queue.add(tN);
}
}
return new int[]{A[ans.y],b[ans.x]};
}
额外思考:
这个题目和2个有序数组a[m],b[n],求前k个a[i]+b[j]的最小值。这个题目也是运用堆的思想,维持大小为n的堆不变,把a[0]+b[j](j从0到n-1)的n个值放入堆中,每次取出堆顶,放入堆顶元素右边的位置进去,比如,堆顶元素是a[1]+b[j],那么放进堆的值就是a[2]+b[j],再调整堆。
总结:
堆的特点:
运用的最多的特点,就是可以直接从堆顶取到最大值或最小值,再次维护只需要log2(n)的复杂度。