Java實現HEAPSORT堆排序演算法

JetPie的部落格發表於2014-09-17

Section 1 – 簡介

Heapsort是一個comparison-based的排序演算法(快排,歸併,插入都是;counting sort不是),也是一種選擇排序演算法(selection sort),一個選擇演算法(selection algorithm)的定義是找到一個序列的k-th order statistic(統計學中的術語),直白的說就是找到一個list中第k-th小的元素。以上都可以大不用懂,heapsort都理解了回來看一下是這回事就是了。同樣,插值排序也是一種選擇排序演算法。

Heapsort的時間複雜度在worst-case是O(nlgn),average-case是O(nlgn);空間複雜度在worst-case是O(1),也就是說heapsort可以in-place實現;heapsort不穩定。

以下順便附上幾種排序演算法的時間複雜度比較(Θ−notation比O−notation更準確的定義了漸進分析(asymptotic analysis)的上下界限,詳細瞭解可以自行google):

Table 1 – 四種排序演算法的running time比較
Algorithm Worst-case Average-case/expected
Insertion sort(插值排序)  Θ(n2)  Θ(n2)
Merge sort(歸併排序)  Θ(nlgn)  Θ(nlgn)
Heapsort(堆排序)  O(nlgn)  O(nlgn)
Quicksort(快速排序)  Θ(n2)  Θ(n2) (expected)

*Additional Part – KNN

heapsort在實踐中的表現經常不如quicksort(儘管quicksort最差表現為 Θ(n2),但quicksort 99%情況下的runtime complexity為 Θ(nlgn)),但heapsort的O(nlgn)的上限以及固定的空間使用經常被運作在嵌入式系統。在搜尋或機器學習中經常也有重要的作用,它可以只返回k個排序需要的值而不管其他元素的值。例如KNN(K-nearest-neighbour)中只需返回K個最小值即可滿足需求而並不用對全域性進行排序。當然,也可以使用divide-and-conquer的思想找最大/小的K個值,這是一個題外話,以後有機會做一個專題比較下。

以下程式為一個簡單的在python中呼叫heapq進行heapsort取得k個最小值,可以大概體現上面所述的特性:

”’
Created On 15-09-2014

@author: Jetpie

”’
import heapq, time
import scipy.spatial.distance as spd
import numpy as np

pool_size = 100000

#generate an 3-d random array of size 10,000
# data = np.array([[2,3,2],[3,2,1],[2,1,3],[2,3,2]])
data = np.random.random_sample((pool_size,3))
#generate a random input
input = np.random.random_sample()
#calculate the distance list
dist_list = [spd.euclidean(input,datum) for datum in data]

#find k nearest neighbours
k = 10

#use heapsort
start = time.time()
heap_sorted = heapq.nsmallest(k, range(len(dist_list)), key = lambda x: dist_list[x])
print(‘Elasped time for heapsort to return %s smallest: %s’%(k,(time.time() – start)))

#find k nearest neighbours
k = 10000

#use heapsort
start = time.time()
heap_sorted = heapq.nsmallest(k, range(len(dist_list)), key = lambda x: dist_list[x])
print(‘Elasped time for heapsort to return %s smallest: %s’%(k,(time.time() – start)))

get_k_smallest

執行結果為:

Elasped time for heapsort to return 10 smallest: 0.0350000858307
Elasped time for heapsort to return 10000 smallest: 0.0899999141693

Section 2 – 演算法過程理解

2.1 二叉堆

完全二叉樹   binary heap

在“堆排序”中的“堆”通常指“二叉堆(binary heap)”,許多不正規的說法說“二叉堆”其實就是一個完全二叉樹(complete binary tree),這個說法正確但不準確。但在這基礎上理解“二叉堆”就非常的容易了,二叉堆主要滿足以下兩項屬性(properties):

#1 – Shape Property: 它是一個完全二叉樹。

#2 – Heap Property: 主要分為max-heap propertymin-heap property(這就是我以前說過的術語,很重要)

|–max-heap property :對於所有除了根節點(root)的節點 i,A[Parent]≥A[i]

  |–min-heap property :對於所有除了根節點(root)的節點 i,A[Parent]≤A[i]

上圖中的兩個二叉樹結構均是完全二叉樹,但右邊的才是滿足max-heap property的二叉堆。

在以下的描述中,為了方便,我們還是用堆來說heapsort中用到的二叉堆。

2.2 一個初步的構想

有了這樣一個看似簡單的結構,我們可以產生以下初步構想來對陣列A做排序:

1.將A構建成一個最大堆(符合max-heap property,也就是根節點最大);

2.取出根節點(how?);

3.將剩下的陣列元素在建成一個最大二叉堆,返回第2步,直到所有元素都被取光。

如果已經想到了以上這些,那麼就差不多把heapsort完成了,剩下的就是怎麼術語以及有邏輯、程式化的表達這個演算法了。

2.3 有邏輯、程式化的表達

通常,heapsort使用的是最大堆(max-heap)。給一個陣列A(我們使用 Java序列[0...n]),我們按順序將它初始化成一個堆:

Input: 

陣列A

Initialization:

 堆初始化

*堆的根節點(root)為A[0];

對這個堆中index為i的節點,我們可以得到它的parent, left child and right child,有以下操作:

Parent(i): parent(i)←A[floor((i−1)/2)]

Left(i): left(i)←A[2∗i+1]

Right(i): right(i)←A[2∗i+2]

通過以上操作,我們可以在任意index-i得到與其相關的其他節點(parent/child)。

在heapsort中,還有三個非常重要的基礎操作(basic procedures):

Max-Heapify(A , i): 維持堆的#2 - Heap Property,別忘了在heapsort中我們指的是max-heap property(min-heap property通常是用來實現priority heap的,我們稍後提及)。

Build-Max-Heap(A): 顧名思義,構建一個最大堆(max-heap)。

Heapsort(A): 在Build-Max-Heap(A)的基礎上實現我們2.2構想中得第2-3步。

其實這三個操作每一個都是後面操作的一部分。

下面我們對這三個非常關鍵的步驟進行詳細的解釋。

Max-Heapify(A , i)

+Max-Heapify的輸入是當前的堆A和index-i,在實際的in-place實現中,往往需要一個heapsize也就是當前在堆中的元素個數。

+Max-Heapify有一個重要的假設:以Left(i)和Right(i)為根節點的subtree都是最大堆(如果樹的知識很好這裡就很好理解了,但為什麼這麼假設呢?在Build-Max-Heap的部分會解釋)。

+有了以上的輸入以及假設,那麼只要對A[i], A[Left(i)]和A[Right(i)]進行比較,那麼會產生兩種情況:

-第一種,最大值(largest)是A[i],那麼基於之前的重要假設,以i為根節點的樹就已經符合#2 - Heap Property了。

-第二種,最大值(largest)是A[Left(i)]或A[Right(i)],那麼交換A[i]與A[largest],這樣的結果是以largest為根節點的subtree有可能打破了#2 - Heap Property,那麼對以largest為根節點的樹進行Max-Heapify(A, largest)的操作。

+以上所述的操作有一個形象的描述叫做A[i] “float down”, 使以i為根節點的樹是符合#2 - Heap Property的,以下的圖例為A[0] ”float down”的過程(注意,以A[1]和A[2]為根節點的樹均是最大堆)。

heapify step 1  heapify step2  heapify step 3  heapify step 4

以下附上reference[1]中的Psudocode:

MAX-HEAPIFY(A, i)
l = LEFT(i)
r = RIGHT(i)
if <= heapsize and A[l] > A[i]
largest = l
else largest = i
if r <= heapsize and A[r] > A[largest]
largest = r
if not largest = i
exchange A[i] with a[largest]
MAX-HEAPIFY(A, largest)

Build-Max-Heap(A)

先附上reference[1]中的Psudocode(做了部分修改,這樣更明白),因為非常簡單:

1 BUILD-MAX-HEAP(A)
2     heapsize = A.length
3     for i = PARENT(A.length-1) downto 0
4         MAX-HEAPIFY(A , i)

+Build-Max-Heap首先找到最後一個有子節點的節點 i=PARENT(A.length−1) 作為初始化(Initialization),因為比 i 大的其他節點都沒有子節點了所以都是最大堆。

+對 i 進行降序loop並對每個 i 都進行Max-Heapify的操作。由於比 i 大的節點都進行過Max-Heapify操作而且 i 的子節點一定比 i 大, 因此符合了Max-Heapify的假設(以Left(i)和Right(i)為根節點的subtree都是最大堆)。

下圖為對我們的輸入進行Build-Max-Heap的過程:

max heap 1  max heap 2  max heap 3

max heap 4  max heap 5  max heap 6

Heapsort(A)

到現在為止我們已經完成了2.2中構想的第一步,A[0]也就是root節點是陣列中的最大值。如果直接將root節點取出,會破壞堆的結構,heapsort演算法使用了一種非常聰明的方法。

+將root節點A[0]和堆中最後一個葉節點(leaf)進行交換,然後取出葉節點。這樣,堆中除了以A[0]為root的樹破壞了#2 - Heap Property,其他subtree仍然是最大堆。只需對A[0]進行Max-Heapify的操作。

+這個過程中將root節點取出的方法也很簡單,只需將heapsize←heapsize−1。

下面是reference[1]中的Psudocode:

1 HEAPSORT(A):
2     BUILD-MAX-HEAP(A)
3     for i = A.length downto 1
4         exchange A[0] with A[i]
5         heapsize =  heapsize -1
6         MAX-HEAPIFY(A , 0)

到此為止就是整個heapsort演算法的流程了。注意,如果你是要閉眼睛也能寫出一個堆排,最好的方法就是理解以上六個重要的操作。

Section 3 – runtime複雜度分析

這一個section,我們對heapsort演算法過程中的操作進行復雜度分析。

首先一個總結:

  • Max-Heapify ~ O(lgn)
  • Build-Max-Heap ~ O(n)
  • Heapsort ~ O(nlgn)

然後我們分析一下為什麼是這樣的。在以下的分析中,我們所指的所有節點i都是從1開始的。

Max-Heapify 

這個不難推導,堆中任意節點 i 到葉節點的高度(height)是lgn。要專業的推導,可以參考使用master theorem。

Build-Max-Heap

在分析heapsort複雜度的時候,最有趣的就是這一步了。

如果堆的大小為n,那麼堆的高度為⌊lgn⌋;

對於任意節點i,i到葉節點的高度是h,那麼高度為h的的節點最多有⌈n/2h+1⌉個,下面是一個大概的直觀證明:

-首先,一個大小為n的堆的葉節點(leaf)個數為⌈n/2⌉:

–還記不記得最後一個有子節點的節點parent(length – 1)是第⌊n/2⌋(注意這裡不是java序號,是第幾個),由此可證葉節點的個數為n - ⌊n/2⌋;

-那麼如果去掉葉節點,剩下的堆的節點個數為n−⌈n/2⌉=⌊n/2⌋,這個新樹去掉葉節點後節點個數為⌊⌊n/2⌋/2⌋ ;

-(這需要好好想一想)以此類推,最後一個樹的葉節點個數即為高度為h的節點的個數,一定小於⌈(n/2)/2h⌉,也就是⌈n/2h+1⌉。

對於任意節點i,i到葉節點的高度是h,執行Max-Heapify所需要的時間為O(h),上面證明過。

那麼Build-Max-Heap的上限時間為(參考reference[1]):

∑⌊lgn⌋h=0⌈n2h+1⌉O(h)=O(n∑⌊lgn⌋h=0h2h)

根據以下定理:

∑∞k=0kxk=x(1−x)2for|x|<1

我們用x=12替換求和的部分得到:

∑∞h=0h2h=1/2(1−1/2)2=2

綜上所述,我們可以求得:

O(n∑⌊lgn⌋h=0h2h)=O(n∑∞h=0h2h)=O(2n)=O(n)

Heapsort

由於Build-Max-Heap複雜度為O(n),有n-1次呼叫Max-Heapify(複雜度為O(lgn)),所有總的複雜度為O(nlgn)

到此為止,所有functions的執行復雜度都分析完了,下面的章節就是使用Java的實現了。

Section 4 – Java Implementation

這個Section一共有兩個內容,一個簡單的Java實現(只有對key排序功能)和一個Priority Queue。

Parameters & Constructors:

protected double A[];
protected int heapsize;

//constructors
public MaxHeap(){}
public MaxHeap(double A[]){
buildMaxHeap(A);
}

求parent/left child/right child:

1 protected int parent(int i) {return (i - 1) / 2;}
2 protected int left(int i) {return 2 * i + 1;}
3 protected int right(int i) {return 2 * i + 2;}

保持最大堆特性:

protected void maxHeapify(int i){
int l = left(i);
int r = right(i);
int largest = i;
if (l <= heapsize – 1 && A[l] > A[i])
largest = l;
if (r <= heapsize – 1 && A[r] > A[largest])
largest = r;
if (largest != i) {
double temp = A[i];
// swap
A[i] = A[largest];
A[largest] = temp;
this.maxHeapify(largest);
}
}

構造一個“最大堆”:

public void buildMaxHeap(double [] A){
this.A = A;
this.heapsize = A.length;

for (int i = parent(heapsize – 1); i >= 0; i–)
maxHeapify(i);
}

對一個array使用heapsort:

public void heapsort(double [] A){
buildMaxHeap(A);

int step = 1;
for (int i = A.length – 1; i > 0; i–) {
double temp = A[i];
A[i] = A[0];
A[0] = temp;
heapsize–;
System.out.println(“Step: ” + (step++) + Arrays.toString(A));
maxHeapify(0);
}
}

main函式:

public static void main(String[] args) {
//a sample input
double [] A = {3, 7, 2, 11, 3, 4, 9, 2, 18, 0};
System.out.println(“Input: ” + Arrays.toString(A));
MaxHeap maxhp = new MaxHeap();
maxhp.heapsort(A);
System.out.println(“Output: ” + Arrays.toString(A));

}

執行結果:

Input: [3.0, 7.0, 2.0, 11.0, 3.0, 4.0, 9.0, 2.0, 18.0, 0.0]
Step: 1[0.0, 11.0, 9.0, 7.0, 3.0, 4.0, 2.0, 2.0, 3.0, 18.0]
Step: 2[0.0, 7.0, 9.0, 3.0, 3.0, 4.0, 2.0, 2.0, 11.0, 18.0]
Step: 3[2.0, 7.0, 4.0, 3.0, 3.0, 0.0, 2.0, 9.0, 11.0, 18.0]
Step: 4[2.0, 3.0, 4.0, 2.0, 3.0, 0.0, 7.0, 9.0, 11.0, 18.0]
Step: 5[0.0, 3.0, 2.0, 2.0, 3.0, 4.0, 7.0, 9.0, 11.0, 18.0]
Step: 6[0.0, 3.0, 2.0, 2.0, 3.0, 4.0, 7.0, 9.0, 11.0, 18.0]
Step: 7[0.0, 2.0, 2.0, 3.0, 3.0, 4.0, 7.0, 9.0, 11.0, 18.0]
Step: 8[2.0, 0.0, 2.0, 3.0, 3.0, 4.0, 7.0, 9.0, 11.0, 18.0]
Step: 9[0.0, 2.0, 2.0, 3.0, 3.0, 4.0, 7.0, 9.0, 11.0, 18.0]
Step: 10[0.0, 2.0, 2.0, 3.0, 3.0, 4.0, 7.0, 9.0, 11.0, 18.0]
Output: [0.0, 2.0, 2.0, 3.0, 3.0, 4.0, 7.0, 9.0, 11.0, 18.0]

heapsort在實踐中經常被一個實現的很好的快排打敗,但heap有另外一個重要的應用,就是Priority Queue。這篇文章只做擴充內容提及,簡單得說,一個priority queue就是一組帶key的element,通過key來構造堆結構。通常,priority queue使用的是min-heap,例如按時間順序處理某些應用中的objects。

為了方便,我用Inheritance實現一個priority queue:

package heapsort;

import java.util.Arrays;

public class PriorityQueue extends MaxHeap{

public PriorityQueue(){super();}
public PriorityQueue(double [] A){super(A);}

public double maximum(){
return A[0];
}

public double extractMax(){
if(heapsize<1)
System.err.println(“no element in the heap”);
double max = A[0];
A[0] = A[heapsize-1];
heapsize–;
this.maxHeapify(0);
return max;
}

public void increaseKey(int i,double key){
if(key < A[i])
System.err.println(“new key should be greater than old one”);

A[i] = key;
while(i>0 && A[parent(i)] <A[i]){
double temp = A[i];
A[i] = A[parent(i)];
A[parent(i)] = temp;
i = parent(i);
}
}

public void insert(double key){
heapsize++;
A[heapsize - 1] = Double.MIN_VALUE;
increaseKey(heapsize – 1, key);
}

public static void main(String[] args) {
//a sample input
double [] A = {3, 7, 2, 11, 3, 4, 9, 2, 18, 0};
System.out.println(“Input: ” + Arrays.toString(A));
PriorityQueue pq = new PriorityQueue();
pq.buildMaxHeap(A);
System.out.println(“Output: ” + Arrays.toString(A));
pq.increaseKey(2, 100);
System.out.println(“Output: ” + Arrays.toString(A));
System.out.println(“maximum extracted: ” + pq.extractMax());
pq.insert(33);
System.out.println(“Output: ” + Arrays.toString(A));

}
}

priorityqueue

執行結果:

Input: [3.0, 7.0, 2.0, 11.0, 3.0, 4.0, 9.0, 2.0, 18.0, 0.0]
Output: [18.0, 11.0, 9.0, 7.0, 3.0, 4.0, 2.0, 2.0, 3.0, 0.0]
Output: [100.0, 11.0, 18.0, 7.0, 3.0, 4.0, 2.0, 2.0, 3.0, 0.0]
maximum extracted: 100.0
Output: [33.0, 18.0, 4.0, 7.0, 11.0, 0.0, 2.0, 2.0, 3.0, 3.0]

Section 5 – 小結

首先要說本文全部是原創,如需要使用,只需要引用一下並不需要通知我。

寫到最後發現有很多寫得很冗餘,也有囉嗦的地方。感覺表達出來對自己的知識鞏固很有幫助。Heapsort真是一個非常有意思的排序方法,是一個通用而不算複雜的演算法,這是決定開始寫blogs後的第一篇文章,一定有很多不足,歡迎討論!之後打算寫一些機器學習和計算機視覺方面的來拋磚引玉。希望通過部落格園這個平臺可以交到更多有鑽研精神的朋友。

相關文章