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

  • 2021 年 12 月 3 日
  • 筆記

前置知識

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

所以得知道完全二叉樹的一些性質:設完全二叉樹的層次為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)

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

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

第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

首先  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 = 2^(k-1) + 2^(k-2) + … 2^(2) + 2^(1) – 2^(0)*(k-1)  = s

可以看出除了最後一項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)

使用遞歸下沉的話,每下沉一次都會調用一次函數,空間複雜度應該與時間複雜度一樣。