5、堆排序(HeapSort)
在接觸“堆排序”前,先回顧一下資料結構C#版筆記--樹與二叉樹 ,其中提到了“完全二叉樹”有一些重要的數學特性:
就是一顆完全二叉樹,如果每個節點按從上到下,從左至右標上序號,則可以用數組來實現順性儲存,同時其序號:
1、如果i>1,則序號為i的父結節序號為i/2(這裡/指整除) 言外之意:整個數組前一半都是父節點,後一半則是分葉節點
2、如果2*i<=n(這裡n為整顆樹的節點總數),則序號為i的左子節點序號為2*i
3、如果2*i +1 <=n,則序號為i的右子節點序號為2*i + 1
好了,再來看看"堆(Heap)"是個神馬玩意兒?
其實,堆就是一顆完全二叉樹,由上面的知識點回顧可以知道,任意給定一個數組,我們就能將它構造成一顆完全二叉樹,也就是建立一個“堆”--ps:還好業內標準稱它為一堆,而不是一坨 :)
其中,堆又可以分為最大堆與最小堆,就是一個最大堆:
簡言之:每個(父)節點的值,都比其子節點值大,這樣的堆就稱為最大堆;反過來類推,如果每個(父)節點的值,都比其子節點小,就叫最小堆。
下面該"堆排序"(HeapSort)登場了,其思路為:
1、先將給定待排序的數組通過一定處理,形成一個“最大堆”
2、然後將根節點(root)與最後一個序號的節點(lastNode)對換,這樣值最大的根節點,就“沉”到所有節點最後了(也就是墊底了),下輪處理就不用理會它了.
3、因為第2步的操作,剩下的這些節點肯定已經不滿足最大堆的定義了(因為把小值的末節點換成根節點了,它的子節點中肯定會有值比它大的),然後再類似第1步的處理,把這些剩下的節點重新排成“最大堆”
4、重複第2步的操作,將“新最大堆的根節點”與“新最大堆的末結點”(其實就是整個數組的倒數第二個節點,因為在第一輪處理中,最大值的節點已經沉到最後了,所以新最大堆的最末節點就是整個數組的倒數第二個節點)對換,這樣第二大的元素也沉到適當的位置了,以後就不用理它了,然後繼續把剩下的節點,重組成最大堆
5、反覆以上過程,直到最後剩下的節點只剩一個為止(即沒辦法再繼續重組成最大堆),這時排序結束,最後剩下的節點,肯定就是值最小的
假設給定數組new int[] {1,3,5,6,4,2},要求用“堆排序演算法”從小到大排序,上面的演算法描述圖解為:
理解以上思路後,堆排序就拆分成了二個問題:
A、如何將數組指定範圍的N個元素建立一個"最大堆"?
B、如何用一定的演算法,反覆調用A中的"最大堆建立"方法,以處理剩下的節點,直到最終只剩一個元素為止
建立最大堆的演算法,完全依賴於完全二叉樹的數學特性,代碼如下:
/// <summary> /// 建立最大堆 /// </summary> /// <param name="arr">待處理的數組</param> /// <param name="low">指定連續待處理元素範圍的下標下限</param> /// <param name="high">指定連續待處理元素範圍的下標上限</param> static void CreateMaxHeap(int[] arr, int low, int high) { if ((low < high) && (high <= arr.Length - 1)) { int j = 0, k = 0, t = 0; //根據完全二叉樹特性,前一半元素都是父節點,所以只需要遍曆前一半即可 for (int i = high / 2; i >= low; --i) { k = i; t = arr[i];//暫存當前節點值 j = 2 * i + 1;//計算左節點下標(注意:數組下標是從0開始的,而完全二叉樹的序號是從1開始的,所以這裡的2*i+1是左子節點,而非右子節點!) while (j <= high) //如果左節點存在 { //如果右節點也存在,且右節點更大 if ((j < high) && (j + 1 <= high) && (arr[j] < arr[j + 1])) { ++j;//將j值調整到右節點的序號,即經過該if判斷後,j對應的元素就是i元素的左、右子節點中值最大的 } //如果本身節點比子節點小 if (t < arr[j]) { arr[k] = arr[j];//將自己節點的值,更新為左右子節點中最大的值 //然後儲存左右子節點中最大元素的下標(因為實際上要做的是將最大子節點與自身進行值交換, //上一步只完成了交換值的一部分,後面還會繼續處理才能完成整個交換) k = j; j = 2 * k + 1;//交換後,j元素就是父節點了,然後重新以j元素為父節點,繼續考量其"左子節點",準備進入新一輪while迴圈 } else //如果本身已經是最大值了,則說明元素i所對應的子樹,已經是最大堆,則直接跳出迴圈 { break; } } //接上面的交換值操作,將最大子節點的元素值替換為t(因為最近的一次if語句中,k=j 了, //所以這裡的arr[k]其實就是arr[j]=t,即完成了值交換的最後一步, //當然如果最近一次的if語句為false,根本沒進入,則這時的k仍然是i,維持原值而已) arr[k] = t; } } }
反覆調用該演算法排序的代碼:
/// <summary> /// 堆排序 /// </summary> /// <param name="arr"></param> static void HeapSort(int[] arr) { int tmp = 0; //初始時,將整個數組排列成"初始最大堆" CreateMaxHeap(arr, 0, arr.Length - 1); for (int i = arr.Length - 1; i > 0; --i) { //將根節點與最末結點對換 tmp = arr[0]; arr[0] = arr[i]; arr[i] = tmp; //去掉沉底的元素,剩下的元素重新排列成“最大堆” CreateMaxHeap(arr, 0, i - 1); } }
點評:這是一種思維方式很獨特的排序方式,時間複雜度跟快速排序類似,也是跟二叉樹有關,為O(Nlog2N),同樣它也是一種不穩定的排序。
6、歸併排序演算法(MergeSort)
思路:將數組中的每個元素看作一個小序列,然後二二合并成一個有序的新序列(這樣序列個數從N變成了N/2,但是每個小序列的長度從1變成2),然後繼續將這些新序列二二合并,得到N/4個序列(每個序列的長度從2變成4),如此反覆,最終得到一個全部排列好的完整序列。這也是演算法中"分治法"的經典案例之一,即分而治之。
這裡反覆要用到將二個序列合并為新序列的處理,封裝成以下方法 :
/// <summary> /// 歸併處理 /// </summary> /// <param name="arr">需要歸併處理的數組</param> /// <param name="len">每段小序列的長度</param> static void Merge(int[] arr, int len) { int m = 0; //臨時順序表的起始位置 int low1 = 0; //第1個有序表的起始位置 int high1; //第1個有序表的結束位置 int low2; //第2個有序表的起始位置 int high2; //第2個有序表的結束位置 int i = 0; int j = 0; //暫存資料表,用於臨時將兩個有序表合并為一個有序表 int[] tmp = new int[arr.Length]; //歸併處理 while ((low1 + len) < arr.Length) { low2 = low1 + len; //第2個有序表的起始位置 high1 = low2 - 1; //第1個有序表的結束位置 //第2個有序表的結束位置 high2 = ( (low2 + len - 1) < arr.Length) ? low2 + len - 1 : arr.Length - 1; i = low1; j = low2; //如果二個有序表都還沒整完 while ((i <= high1) && (j <= high2)) { if (arr[i] <= arr[j])//如果 第1個有序表的元素小於第2個有序表的對應元素,則直接複製第1個有序表的元素到暫存資料表 { tmp[m++] = arr[i++]; } else//否則,複製第2個有序表的元素到暫存資料表 { tmp[m++] = arr[j++]; } } //經過上面的處理後,如果第1個有序表還有元素 while (i <= high1) { tmp[m++] = arr[i++]; } //經過上面的處理後,如果第2個有序表還有元素 while (j <= high2) { tmp[m++] = arr[j++]; } low1 = high2 + 1;//將low1"指標"指到“處理完的2個有序表”之後,以方便下面將剩餘未處理完的元素複製到暫存資料表 } i = low1; //將尚未處理到的元素複製到暫存資料表 while (i < arr.Length) { tmp[m++] = arr[i++]; } //將暫存資料表的元素複製到原數組 for (i = 0; i < arr.Length; ++i) { arr[i] = tmp[i]; } }
排序處理:
/// <summary> /// 歸併排序 /// </summary> /// <param name="arr"></param> static void MergeSort(int[] arr) { int k = 1; //歸併增量 while (k < arr.Length) { Merge(arr, k); k *= 2; } } }
點評:倆倆合并,又是跟2有關的,哈哈,電腦領域真的很2啊!所以其時間複雜度又是O(Nlog2N),但是該演算法需要很多的臨時數組,所以其空間複雜度較其它演算法都要大一些為O(N),此外它是穩定的排序方法。
排序方法小結:(原書的小結還算不錯,就懶得自己再寫了,直接從原電子書上copy過來記錄下)
排序在電腦程式設計中非常重要,上面介紹的各種排序方法各有優缺點,適用的場合也各不相同。
在選擇排序方法時應考慮的因素有:
(1)待排序記錄的數目 n 的大小;
(2)記錄本身除關鍵碼外的其它資訊量的大小;
(3)關鍵碼(即元素值)的情況;
(4)對排序穩定性的要求;
(5)語言工具的條件,輔助空間的大小等。
綜合考慮以上因素,可以得出如下結論:
(1)若排序記錄的數目 n 較小(如 n≤50)時,可採用直接插入排序或簡單選擇排序。由於直接插入排序所需的記錄移動操作較簡單選擇排序多,因而當記錄本身資訊量較大時,用簡單選擇排序比較好。
(2)若記錄的初始狀態已經按關鍵碼基本有序,可採用直接插入排序或冒泡排序。
(3)若排序記錄的數目n較大,則可採用時間複雜度為O(nlog2n)的排序方法(如快速排序、堆排序或歸併排序等)。
快速排序的平均效能最好,在待排序序列已經按關鍵碼隨機分布時,快速排序最適合。快速排序在最壞情況下的時間複雜度是O(n2),而堆排序在最壞情況下的時間複雜度不會發生變化,並且所需的輔助空間少於快速排序。但這兩種排序方法都是不穩定的排序,若需要穩定的排序方法,則可採用歸併排序。
(4)前面討論的排序演算法,都是採用順序儲存結構。在待排序的記錄非常多時,為避免花大量的時間進行記錄的移動,可以採用鏈式儲存結構。直接插入排序和歸併排序都可以非常容易地在鏈表上實現,但快速排序和堆排序卻很難在鏈表上實現。此時,可以提取關鍵碼建立索引表,然後對索引表進行排序。也可以引入一個整形數組 t[n]作為輔助表,排序前令t[i]=i,1≤i≤n。若排序演算法中要求交換記錄 R[i]和 R[j],則只須交換 t[i]和 t[j]即可。排序結束後,數組 t[n]就存放了記錄之間的循序關聯性