算法——排序之堆排序

堆排序是一種基於堆的排序。要了解堆排序,首先我們要了解堆的特性。

那麼什麼是堆呢?

這裏我們使用大頂堆,並且是二叉堆,且用數組實現的方式作爲例子。

在二叉堆的數組中,每個元素都要保證大於等於兩個特定位置的元素,這裏所說的特定位置,在樹結構中就是它的子節點。所有節點都要滿足上面所說的情況。如果我們畫成二叉樹的形式,我們就很容易理解了。


可以看到,如果滿足上面的特點的話,那麼樹的根節點的元素的值一直是最大的,這就是大頂堆。同樣的也有小頂堆,根節點的元素是最小的。

而對於數組實現的二叉堆來說,他們的父子關係非常容易確認。我們設數組中a[1]爲根節點。那麼他的子節點就是2,3。對於某個節點k呢?他的子節點就是2*k和2*k+1。父節點就是k/2。當然這需要我們將數組a[1]作爲根節點,而不是用a[0]。用a[0]也可以,但是父子節點關係沒這麼簡單而已,不過也差不了多少。習慣上用a[1]作爲根節點。我們這裏也採用這種方式。


看到這個結構,有沒有對使用堆結構的排序有一種恍然大悟的想法呢?

對,我們可以使用堆結構來優化我們的選擇排序。因爲選擇排序每一輪就是找到最大的元素,然後固定位置。而堆結構能夠非常快速的給我們他的最大值。這樣,他一直給我們提供最大值的話,我們就可以輕而易舉的完成選擇排序了!


不過我們首先要了解的是,如何建立一個堆。還需要了解建立這個堆,以及這個堆給我們提供最大值的效率是多少?如果建立這個堆花費代價很大,並且給我們提供最大值也需要很大的代價的話,上面提到的就完全沒有意義了。幸運的是,建立堆,並且獲取最大值代價並不大。


給我們一個數組,我們怎麼樣可以使得這個數組是一個二叉堆呢?

我們採用下沉的辦法。我們假設原本這個是一個堆結構,但是突然,其中一個元素之改變了,那麼我們如何將這個堆結構重新維護起來呢?

如圖所示:除了下沉的節點之外,其他的節點已經滿足堆結構的要求。


對於葉節點我們不需要管,我們只需要遍歷所有不是葉節點的節點,讓他們下沉。因爲我們認爲只有葉節點,已經是一個堆結構了。這樣,從後向前,因爲後面的永遠是一個堆結構,每次我們只對一個元素進行定位,也就是下沉,就能夠將堆建立起來。

這樣,當我們下沉完畢的時候,這個堆就創建好了。



堆我們已經創建好了,那麼我們如何利用他進行排序呢?

我們採用這個思路:每次將最大的元素和數組末尾的元素進行位置的交換。

但是將數組末尾的元素放在根節點上,堆結構就被破壞了。所以我們需要重新維護這個堆。因爲我們除了根節點和數組末尾的元素之外,其他地方都已經是一個堆結構了。所以我們可以將交換上來的,新的根節點下沉。當然,這個時候建立堆的數組就應該縮小了,應該將數組末尾的元素剔除,也就是數組的大小減一。這樣才能滿足除了根節點之外,數組已經是一個堆的這麼一個前提。

當我們慢慢的將根節點的元素放到數組的末尾去,因爲根節點的元素永遠是當前數組中最大的,所以我們就可以從後向前慢慢的排序起來。

如圖所示:



代碼如下:

public static void sort(Comparable[] a) {
	// 先構造堆
	int N = a.length;
	for (int i = N / 2; i >= 1; i--) {
		sink(a, i, N);
	}
	// 排序,銷燬堆
	while (N-- > 1) {
		swap(a, 1, N);
		sink(a, 1, N);
	}
}

public static void sink(Comparable[] a, int begin, int length) {
	while (begin * 2 < length) {
		int maxIndex = begin * 2;
		if (maxIndex + 1 < length && less(a[maxIndex], a[maxIndex + 1])) { // 如果有兩個子節點,選擇較大的子節點
			maxIndex++;
		}

		if (!less(a[begin], a[maxIndex]))
			break;
		swap(a, begin, maxIndex);
		begin = maxIndex;
	}
}

當堆已經存在的時候,我們插入一個元素所需時間爲O(logN)。我們需要插入N/2個元素。所以堆排序的時間複雜度是O(nlogn)。

而對於空間複雜度,我們以外的發現,堆排序的空間複雜度僅僅爲O(1),並且它最壞複雜度也是O(nlogn)。

時間複雜度達到了下限,空間複雜度也是常數級別的。那麼是不是意味着堆排序超6,碾壓其他算法呢?

但我們的應用一般都不會使用堆排序,用的最多的還是快排,爲什麼呢?


我們先來比較一下歸併排序,快速排序,堆排序的效率:

public static void main(String[] args) {
	final int NUM = 1000000;
	Integer[] a1 = new Integer[NUM];
	Integer[] a2 = new Integer[NUM];
	Integer[] a3 = new Integer[NUM];
	Integer[] a4 = new Integer[NUM];
	a1[0] = a2[0] = a3[0] = a4[0] = -1;
	for (int i = 1; i < NUM; i++) {
		a1[i] = (int) (Math.random() * NUM);
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
	}

	long startTime;
	long endTime;
	
	startTime = System.currentTimeMillis(); // 獲取開始時間
	QuickSort.sort2(a2);
	assert isSorted(a2);
	endTime = System.currentTimeMillis();
	System.out.println("Quick排序cost: " + (endTime - startTime) + " ms");
	
	startTime = System.currentTimeMillis(); // 獲取開始時間
	MergeSort.sort2(a3);
	assert isSorted(a3);
	endTime = System.currentTimeMillis();
	System.out.println("Merge排序cost: " + (endTime - startTime) + " ms");
	
	startTime = System.currentTimeMillis(); // 獲取開始時間
	HeapSort.sort(a1);
	assert isSorted(a1);
	endTime = System.currentTimeMillis();
	System.out.println("Heap排序cost: " + (endTime - startTime) + " ms");
}
我們改變一下數組大小。

當數組大小爲1000時:

Quick排序cost: 4 ms
Merge排序cost: 2 ms
Heap排序cost: 1 ms
數組大小爲10000時:

Quick排序cost: 10 ms
Merge排序cost: 9 ms
Heap排序cost: 10 ms
數組大小爲100000時:

Quick排序cost: 79 ms
Merge排序cost: 85 ms
Heap排序cost: 121 ms
數組大小爲1000000時:

Quick排序cost: 421 ms
Merge排序cost: 470 ms
Heap排序cost: 929 ms
我們發現,當數組越來越大的時候,堆排序的效率就被慢慢拉開了。這是爲什麼呢?

這是由於計算機緩存的原因。因爲在堆排序中,數組很少會和相鄰的元素進行比較,這對於現代操作系統來說,就不是一個很好的東西了。因爲它的緩存命中概率很小。在需要排序的數組很小的時候,可以一次把數組都讀到緩存中,這就和快排,歸併排序差不多。但是當數組變大的時候,就不能把數組都放到緩存中了,所以效率一下子就低下來了。在這點上,數組大了之後,甚至還比不過希爾排序。快排之所以快,其中一點非常重要的原因就是因爲命中率極高。


但這是不是意味着堆排序沒卵用呢?

也不是這樣的,在沒有多少緩存,並且不能給我們提供很大的空間的時候,堆排序就非常有用了。例如在嵌入式系統中,堆排序是重要的排序手段。

而且用堆實現的優先隊列在很多應用中都是廣爲使用的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章