前言
這篇仍然是面試筆試中的一篇小總結。再次申明:不足之處請各位不吝賜教。
堆排序算法
首先說說什麼叫堆,以及和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],再調整堆。
總結:
堆的特點:
運用的最多的特點,就是可以直接從堆頂取到最大值或最小值,再次維護只需要logn的複雜度。