排序演算法:堆排序的實現和時間複雜度分析

划水的魚dm發表於2021-12-03

前置知識

堆排序是將陣列看成了一個二叉樹,並且是一個完全二叉樹,再進行排序

所以得知道完全二叉樹的一些性質:設完全二叉樹的層次為k,完全二叉樹的節點數量在兩種情況之間

  • 節點數量最大為2- 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)

第一階段:構建堆,下沉所有父節點

首先在構建堆時,需要進行節點的下沉,計算最多下沉次數的時間

  1. 第k層的2^(k-1)個節點都是葉子節點,下沉0次
  2. 第k-1層的2^(k-2)個節點,下沉1次
  3. 。。。。。。
  4. 第2層的有2個節點,下沉k-2次
  5. 第1層的只有1個節點,下沉k-1次
  • 總共下沉的時間複雜度為:s = 2^(k-2) * 1 + 2^(k-3) * 2 + ...+ 2^(1)*(k-2) + 2^(0)*(k-1)

然後使用神奇的數學知識算出這個s,將s兩邊同時乘以2

  1. 首先  2s = 2^(k-1)*1 + 2^(k-2) * 2 + 2^(k-3) * 3 +.... + 2^(2)*(k-2) + 2^(1)*(k-1)
  2. 剛才的                  s =  2^(k-2) * 1 + 2^(k-3) * 2 + ... + 2^(2)*(k-3) + 2^(1)*(k-2) + 2^(0)*(k-1)
  3. 然後  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)

複雜的看:

  1. 二叉樹每次取出一個節點後,n減1,層次k可能會變化
  2. 第i個節點下沉的次數是 log2(n-i+1)
  3. 第一個元素下沉log2(n)次,最後一個元素下沉0次,需要下沉的元素有n-1個
  • 得到總時間為:log2(n) + log2(n-1) + .... + log2(2) = log2(n!) 

太複雜了感覺,就約等於O(nlogn)吧

所以總的時間複雜度為O(n) + O(nlogn) = O(nlogn)

空間複雜度

迴圈下沉方式:原地修改陣列,沒有開闢額外空間,空間複雜度為O(1)

使用遞迴下沉的話,每下沉一次都會呼叫一次函式,空間複雜度與時間複雜度一樣

相關文章