前置知識
堆排序是將陣列看成了一個二叉樹,並且是一個完全二叉樹,再進行排序
所以得知道完全二叉樹的一些性質:設完全二叉樹的層次為k,完全二叉樹的節點數量在兩種情況之間
- 節點數量最大為2k - 1,最後一層的節點是滿的,有2k-1個節點
- 節點數量最小為2k-1,最後一層只有一個節點
除了最後一層外,第i層的節點數量永遠是2i-1個。
以陣列的下標當做節點的序號,即下標為i的元素對應二叉樹的第i-1個節點,左右兩個孩子的下標分別是(2*i+1)和(2*i+2)
- 陣列下標從1開始的話,下標i對應第i個節點,並且左右孩子的下標是2*i和2*i+1
設計思路
對於一個無序的完全二叉樹,排序的思路是先得到最小的元素a,再將a從二叉樹刪除,再得到二叉樹的最小元素b,依次得到有序的所有元素
首先,需要進行建成一個最小堆(最大堆):從最後一個父節點(非葉子節點)開始,下沉所有父節點
- 最大堆:所有父節點的值比左右孩子大
- 最小堆:所有父節點的值比左右孩子小
- 下沉:父節點的值a,與左右孩子的值進行比較,不滿足堆的定義就交換,交換後值為a的節點(不為葉子節點的話)作為父節點繼續下沉,與它的孩子節點交換
遍歷陣列,所有父節點下沉完畢,第一個元素(堆頂節點)node1就是陣列的最小(大)值
要得到所有排好序的元素,需要將元素一個個取出。首先取出node1:與堆的最後一個節點node2交換位置,然後從堆中刪除node1
- 為了保持完全二叉樹的性質,所以將元素放到最後一個節點再刪除
- 刪除不是從陣列中刪除,只是不將它看做二叉樹的一部分
刪除node1後,node2就是第一個節點,然後將node2下沉,因為當前只有node2節點不滿足堆的定義,下沉後又恢復成堆,又可以取出第一個節點
從堆中不斷取出第一個節點,一直到堆為空,陸續取出的節點便是排好序的陣列
程式碼實現
通過構建最大堆,再不斷取出堆頂節點到陣列尾部,實現從小到大的排序
父節點下沉的方式有迴圈和遞迴兩種,在迴圈下沉中沒有交換,而是使用了單向賦值,有一定的優化效果
import java.util.Arrays; import java.util.Random; public class HeadSort { public static void main(String[] args) { //測試程式碼 int[] arr = new int[20]; for (int i = 0; i < arr.length; i++) { arr[i] = new Random().nextInt() % 1000; } System.out.println(Arrays.toString(arr)); heapSort(arr); System.out.println(Arrays.toString(arr)); } public static int[] heapSort(int[] arr) { // 構建最大堆 buildHead(arr); // 每次將堆頂節點移到陣列尾部,調整堆大小 for (int i = 0; i < arr.length; i++) { // 即將要被堆刪除的節點序號 int last = arr.length - i - 1; swap(arr, 0, last); // 改變傳遞的len參數列示堆將節點刪除 heapify1(arr, 0, last); } return arr; } public static void buildHead(int[] arr) { int len = arr.length; //在滿二叉樹中,葉子節點數量是非葉子節點的2倍+1 //第n-1層的最後一個節點就是len/2 for (int i = len / 2; i >= 0; i--) { heapify1(arr, i, len); } } //遞迴下沉方式 public static void heapify1(int[] arr, int parentIndex, int len) { // 左右孩子節點 int left = 2 * parentIndex + 1; int right = 2 * parentIndex + 2; // 該父節點沒有左孩子 if (left >= len) { return; } int latest = left; /* 將節點進行下沉,如果進行了交換,還得繼續下沉,一直到成為葉子節點 */ // 得出左右孩子中的最大值 if (right < len && arr[right] > arr[left]) { latest = right; } if (arr[latest] > arr[parentIndex]) { swap(arr, parentIndex, latest); heapify1(arr, latest, len); } } //迴圈下沉方式 public static void heapify2(int[] arr, int parentIndex, int len) { //先將父節點值儲存下來,跟左右的孩子比較,如果大於,跳出迴圈,如果小於,進行交換 //交換之後節點繼續下沉,一直到成為葉子節點 int tmp = arr[parentIndex]; int children = 2 * parentIndex + 1; while (children < len) { // 左孩子大於右孩子,用左孩子的值與父節點比較 if (children + 1 < len && arr[children + 1] > arr[children]) { children++; } /* 父節點大於孩子的值,不用交換 使用的是tmp,而不是arr[parentIndex],因為一直是單向賦值,沒有交換 直到確認tmp屬於哪個節點,在迴圈外面將tmp賦值 */ if (tmp >= arr[children]) { break; } // 將孩子節點的值賦給父節點,孩子節點暫時不變 arr[parentIndex] = arr[children]; parentIndex = children; children = children * 2 + 1; } arr[parentIndex] = tmp; } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } }
複雜度的計算
設陣列為滿二叉樹來計算複雜度:樹有n個節點、有k層,滿二叉樹的定義有: 2k-1 = n , k = log2(n+1)
第一階段:構建堆,下沉所有父節點
首先在構建堆時,需要進行節點的下沉,計算最多下沉次數的時間
- 第k層的2^(k-1)個節點都是葉子節點,下沉0次
- 第k-1層的2^(k-2)個節點,下沉1次
- 。。。。。。
- 第2層的有2個節點,下沉k-2次
- 第1層的只有1個節點,下沉k-1次
- 總共下沉的時間複雜度為:s = 2^(k-2) * 1 + 2^(k-3) * 2 + ...+ 2^(1)*(k-2) + 2^(0)*(k-1)
然後使用神奇的數學知識算出這個s,將s兩邊同時乘以2
- 首先 2s = 2^(k-1)*1 + 2^(k-2) * 2 + 2^(k-3) * 3 +.... + 2^(2)*(k-2) + 2^(1)*(k-1)
- 剛才的 s = 2^(k-2) * 1 + 2^(k-3) * 2 + ... + 2^(2)*(k-3) + 2^(1)*(k-2) + 2^(0)*(k-1)
- 然後 2s - s = s = 2^(k-1) + 2^(k-2) + ... 2^(2) + 2^(1) - 2^(0)*(k-1)
- 可以看到除了最後一項2^(0)*(k-1)外,前面k-1項是一個以2位公比,2為首項的等比數列
使用等比數列求和公式:a1(1-q^n)/(1-q) ,得出 s = 2^(k) - 2 - 1*(k-1) = 2^(k) - k - 1
又由上面的 2^(k) - 1 = n , k = log2(n+1) 得出 時間複雜度與 陣列長度n的關係:
s = n - log2(n+1) 也就是 O(n)複雜度
第二階段:陸續取出第一個元素,下沉新的堆頂節點
簡單的看:
有n個節點需要取出,每次取出後新節點下沉 log2(n+1),時間複雜度為 O(nlogn)
複雜的看:
- 二叉樹每次取出一個節點後,n減1,層次k可能會變化
- 第i個節點下沉的次數是 log2(n-i+1)
- 第一個元素下沉log2(n)次,最後一個元素下沉0次,需要下沉的元素有n-1個
- 得到總時間為:log2(n) + log2(n-1) + .... + log2(2) = log2(n!)
太複雜了感覺,就約等於O(nlogn)吧
所以總的時間複雜度為O(n) + O(nlogn) = O(nlogn)
空間複雜度
迴圈下沉方式:原地修改陣列,沒有開闢額外空間,空間複雜度為O(1)
使用遞迴下沉的話,每下沉一次都會呼叫一次函式,空間複雜度與時間複雜度一樣