排序算法之堆排序(Heapsort)解析

一.堆排序的優缺點(pros and cons)html

(仍是簡單的說說這個,畢竟沒有必要浪費時間去理解一個糟糕的的算法)算法

優勢:數組

  1. 堆排序的效率與快排、歸併相同,都達到了基於比較的排序算法效率的峯值(時間複雜度爲O(nlogn))
  2. 除了高效以外,最大的亮點就是隻須要O(1)的輔助空間了,既最高效率又最節省空間,只此一家了
  3. 堆排序效率相對穩定,不像快排在最壞狀況下時間複雜度會變成O(n^2)),因此不管待排序序列是否有序,堆排序的效率都是O(nlogn)不變(注意這裏的穩定特指平均時間複雜度=最壞時間複雜度,不是那個「穩定」,由於堆排序自己是不穩定的)

缺點:(從上面看,堆排序幾乎是完美的,那麼爲何最經常使用的內部排序算法是快排而不是堆排序呢?)優化

  1. 最大的也是惟一的缺點就是——堆的維護問題,實際場景中的數據是頻繁發生變更的,而對於待排序序列的每次更新(增,刪,改),咱們都要從新作一遍堆的維護,以保證其特性,這在大多數狀況下都是沒有必要的。(因此快排成爲了實際應用中的老大,而堆排序只能在算法書裏面頂着光環,固然這麼說有些過度了,當數據更新不很頻繁的時候,固然堆排序更好些...)

二.內部原理spa

首先要知道堆排序的步驟:htm

  1. 構造初始堆,即根據待排序序列構造第一個大根堆或者小根堆(大根堆小根堆是什麼?這個不解釋了,稻草垛知道吧..)
  2. 首尾交換,斷尾重構,即對斷尾後剩餘部分從新構造大(小)根堆
  3. 重複第二步,直到首尾重疊,排序完成

按小根堆排序結果是降序(或者說是非升序,不要在乎這種細節..),按大根堆排序的結果是升序blog

上面這句話乍看好像不對(小根堆中最小元素在堆頂,數組組堆頂元素就是a[0],怎麼會是降序?),不過不用質疑這句話的正確性,看了下面這幾幅圖就明白了:排序

假設待排序序列是a[] = {7, 1, 6, 5, 3, 2, 4},而且按大根堆方式完成排序get

  • 第一步(構造初始堆):

{7, 5, 6, 1, 3, 2, 4}已經知足了大根堆,第一步完成it

  • 第二步(首尾交換,斷尾重構):

  • 第三步(重複第二步,直至全部尾巴都斷下來):

無圖,眼睛畫瞎了,mspaint實在很差用。。到第二步應該差很少了吧,剩下的用筆也就畫出來了。。

其實核心就是「斷尾」,但可悲的是全部的資料上都沒有明確說出來,但是,還有比「斷尾」更貼切的描述嗎?

三.實現細節

原理介紹中給出的圖基本上也說清楚了實現細節,因此這裏只關注代碼實現

  • 首先是本身寫出來的大根堆方式實現:
#include<stdio.h>

//構造大根堆(讓a[m]到a[n]知足大根堆)
void HeapAdjust(int a[], int m, int n){
	int temp;
	int max;
	int lc;//左孩子
	int rc;//右孩子

	while(1){
		//獲取a[m]的左右孩子
		lc = 2 * m + 1;
		rc = 2 * m + 2;
		//比較a[m]的左右孩子,max記錄較大者的下標
		if(lc >= n){
			break;//不存在左孩子則跳出
		}
		if(rc >= n){
			max = lc;//不存在右孩子則最大孩子爲左孩子
		}
		else{
			max = a[lc] > a[rc] ? lc : rc;//左右孩子都存在則找出最大孩子的下標
		}
		//判斷並調整(交換)
		if(a[m] >= a[max]){//父親比左右孩子都大,不須要調整,直接跳出
			break;
		}
		else{//不然把小父親往下換
			temp = a[m];
			a[m] = a[max];
			a[max] = temp;
			//準備下一次循環,注意力移動到孩子身上,由於交換以後以孩子爲根的子樹可能不知足大根堆
			m = max;
		}
	}
}

void HeapSort(int a[], int n){
	int i,j;
	int temp;

	//自下而上構造小根堆(初始堆)
	for(i = n / 2 - 1;i >= 0;i--){//a[n/2 - 1]剛好是最後一個非葉子節點(葉子節點已經知足小根堆,只須要調整全部的非葉子節點),一點小小的優化
		HeapAdjust(a, i, n);
	}

	printf("初始堆: ");
	for(i = 0;i < n;i++){
		printf("%d ", a[i]);
	}
	printf("\n");

	for(i = n - 1;i > 0;i--){
		//首尾交換,斷掉尾巴
		temp = a[i];
		a[i] = a[0];
		a[0] = temp;
		//斷尾後的部分從新調整
		HeapAdjust(a, 0, i);

		/*
		printf("第%d次(i - 1 = %d): ", n - i, i - 1);
		for(j = 0;j < n;j++){
			printf("%d ", a[j]);
		}
		printf("\n");
		*/
	}
}

main(){
	//int a[] = {5, 6, 3, 4, 1, 2, 7};
	//int a[] = {1, 2, 3, 4, 5, 6, 7};
	//int a[] = {7, 6, 5, 4, 3, 2, 1};
	int a[] = {7, 1, 6, 5, 3, 2, 4};
	int m, n;
	int i;

	m = 0;
	n = sizeof(a) / sizeof(int);
	//HeapAdjust(a, m, n);
	HeapSort(a, n);
	printf("結果: ");
	for(i = 0;i < n;i++){
		printf("%d ", a[i]);
	}
	printf("\n");
}

P.S.代碼中註釋極其詳盡,由於是徹底一步一步本身想着寫出來的,應該不難理解。看代碼說話,在此多說無益。

  • 接下來給出書本上的大根堆方式實現:
#include<stdio.h>

void HeapAdjust(int a[], int m, int n){
	int i;
	int t = a[m];
	
	for(i = 2 * m + 1;i <= n;i = 2 * i + 1){
		if(i < n && a[i + 1] > a[i])++i;
		if(t >= a[i])break;
		//把空缺位置往下放
		a[m] = a[i];
		m = i;
	}
	a[m] = t;//只作一次交換,步驟上的優化
}

void HeapSort(int a[], int n){
	int i;
	int t;

	//自下而上構造大根堆
	for(i = n / 2 - 1;i >= 0;--i){
		HeapAdjust(a, i, n - 1);
	}

	printf("初始堆: ");
	for(i = 0;i < n;i++){
		printf("%d ", a[i]);
	}
	printf("\n");

	for(i = n - 1;i > 0;i--){
		//首尾交換,斷掉尾巴
		t = a[i];
		a[i] = a[0];
		a[0] = t;
		//對斷尾後的部分從新建堆
		HeapAdjust(a, 0, i - 1);
	}
}

main(){
	//int a[] = {5, 6, 3, 4, 1, 2, 7};
	//int a[] = {1, 2, 3, 4, 5, 6, 7};
	//int a[] = {7, 6, 5, 4, 3, 2, 1};
	int a[] = {7, 1, 6, 5, 3, 2, 4};
	int m, n;
	int i;

	m = 0;
	n = sizeof(a) / sizeof(int);
	//HeapAdjust(a, m, n);
	HeapSort(a, n);
	printf("結果: ");
	for(i = 0;i < n;i++){
		printf("%d ", a[i]);
	}
	printf("\n");
}

P.S.書本上的代碼短了很多,不只僅是篇幅上的優化,也有實實在在的步驟上的優化,細微差異也在註釋中說明了。但這種程度的優化卻使得代碼的可讀性大大下降,因此一次次拿起算法書,又一次次放下。。(實際應用中咱們能夠對書本上的代碼作形式上的優化,在保持其高效性的同時儘量的提高其可讀性。。)

  • 最後是在研究過書本上的算法以後,結合其優化措施,寫出的小根堆方式實現(網上的資料可能是大根堆方式的,其實原理都同樣,這裏只是爲了不枯燥無趣。。):
#include<stdio.h>

//構造小根堆(讓a[m]到a[n]知足小根堆)
void HeapAdjust(int a[], int m, int n){
	int i;
	int t = a[m];
	int temp;
	
	for(i = 2 * m + 1;i <= n;i = 2 * i + 1){
		//a[m]的左右孩子比較,i記錄較小者的下標
		if(i < n && a[i + 1] < a[i]){
			i = i + 1;
		}
		if(t <= a[i]){
			break;
		}
		else{//把空缺位置往下換
			//把較小者換上去
			temp = a[m];
			a[m] = a[i];
			a[i] = temp;
			//準備下一次循環
			m = i;
		}
	}
}

void HeapSort(int a[], int n){
	int i, j;
	int temp;

	//自下而上構造小根堆(初始堆)
	for(i = n / 2 - 1;i >= 0;i--){//a[n/2 - 1]剛好是最後一個非葉子節點(葉子節點已經知足小根堆,只須要調整全部的非葉子節點),一點小小的優化
		HeapAdjust(a, i, n);
	}

	printf("初始堆: ");
	for(i = 0;i < n;i++){
		printf("%d ", a[i]);
	}
	printf("\n");

	//把每一個元素都調整到應該去的位置
	for(i = n - 1; i > 0;i--){
		//首尾交換
		temp = a[i];
		a[i] = a[0];
		a[0] = temp;
		//斷尾後剩餘部分從新調整
		HeapAdjust(a, 0, i - 1);
	}
}

main(){
	//int a[] = {7, 6, 5, 4, 3, 2, 1};
	//int a[] = {1, 5, 6, 4, 3, 2, 7};
	int a[] = {1, 2, 3, 4, 5, 6, 7};
	int m, n;
	int i;

	m = 0;
	n = sizeof(a) / sizeof(int);
	//HeapAdjust(a, m, n);
	HeapSort(a, n);
	printf("結果: ");
	for(i = 0;i < n;i++){
		printf("%d ", a[i]);
	}
	printf("\n");
}

P.S.註釋依然詳盡,看代碼,不廢話

四.總結

堆排序的步驟就幾個字而已:建堆 -> 首尾交換,斷尾重構 -> 重複第二步,直到斷掉全部尾巴

還有比這更清晰更明瞭的描述嗎?

到如今咱們已經掌握了幾個有用的排序算法了:

快速排序歸併排序、堆排序

那麼實際應用中要如何選擇呢?有這些選擇標準:

  1. 若n較小,採用插入排序和簡單選擇排序。因爲直接插入排序所需的記錄移動操做比簡單選擇排序多,因此當記錄自己信息量比較大時,用簡單選擇排序更好。
  2. 若待排序序列基本有序,能夠採用直接插入排序或者冒泡排序
  3. 若n較大,應該採用時間複雜度最低的算法,好比快排,堆排或者歸併
    • 細分的話,當數據隨機分佈時,快排最佳(這與快排的硬件優化有關,在以前的博文中有提到過)
    • 堆排只須要一個輔助空間,並且不會出現快排的最壞狀況
    • 快排和堆排都是不穩定的,若是要求穩定的話能夠採用歸併,還能夠把直接插入排序和歸併結合起來,先用直接插入得到有序碎片,再歸併,這樣獲得的結果也是穩定的,由於直接插入是穩定的

說明:在理解「斷尾」的過程當中參考了前輩的博文,特此感謝

相關文章
相關標籤/搜索