數據結構與算法——堆排序


注意:學習本篇需要學會二叉樹 關於二叉樹的學習 請看 數據結構與算法——二叉樹

基本介紹

堆排序(英語:Heapsort)是利用 這種 數據結構 而設計的一種排序算法,堆是一個近似完全二叉樹的結構,它是一種選擇排序,最壞 、最好、平均時間復雜度均為 O(nlogn)它是不穩定排序

堆是具有以下性質的完全二叉樹:

  • 大頂堆:每個節點的值都 大於或等於 其左右孩子節點的值

    注:沒有要求左右值的大小關系

  • 小頂堆:每個節點的值都 小於或等於 其左右孩子節點的值

    注:沒有要求左右值的大小關系

舉例說明:

大頂堆舉例

對堆中的節點按層進行編號,映射到數組中如下圖

大頂堆特點:arr[i] >= arr[2*i+1] && arr[i] >= arr[2*i+2],i 對應第幾個節點,i 從 0 開始編號

小頂堆舉例

小頂堆特點:arr[i] <= arr[2*i+1] && arr[i] <= arr[2*i+2],i 對應第幾個節點,i 從 0 開始

排序說明

  • 升序:一般采用大頂堆
  • 降序:一般采用小頂堆

基本思想

  1. 將待排序序列構造成一個大頂堆

    注意:這里使用的是數組,而不是一棵二叉樹,用的是順序存儲二叉樹

    關於順序存儲二叉樹請看 數據結構與算法——二叉樹

  2. 此時:整個序列的 最大值就是堆頂的根節點

  3. 將其 與末尾元素進行交換,而后此時末尾就是最大值

  4. 然后將剩余 n-1 個元素重新構造成一個堆,這樣 就會得到第 n 個元素的次小值。如此反復,便能的得到一個有序序列。

動圖初體驗:

堆排序步驟圖解

對數組 4,6,8,5,9 進行堆排序,將數組升序排序

步驟一:構造初始堆

  1. 給定無序序列結構 如下:注意這里的操作用數組,樹結構只是參考理解

    下面將給定無序序列構造成一個大頂堆。

  2. 此時從最后一個非葉子節點開始調整從左到右,從上到下進行調整。

    葉節點不用調整,第一個非葉子節點 arr.length/2-1 = 5/2-1 = 1,也就是 元素為 6 的節點。

    比較時,先讓 5 與 9 比較,得到最大的那個,再和 6 比較,發現 9 大於 6,則調整他們的位置。
    

  3. 找到第二個非葉子節點 4,由於 [4,9,8] 中,9 元素最大,則 4 和 9 進行交換

  4. 此時,交換導致了子根 [4,5,6] 結構混亂,將其繼續調整。[4,5,6] 中 6 最大,將 4 與 6 進行調整。

    此時,就將一個無序序列構造成了一個大頂堆。

步驟二:將堆頂元素與末尾元素進行交換

將堆頂元素與末尾元素進行交換,使其末尾元素最大。然后繼續調整,再將堆頂元素與末尾元素交換,得到第二大元素。如此反復進行交換、重建、交換

  1. 將堆頂元素 9 和末尾元素 4 進行交換

  2. 重新調整結構,使其繼續滿足大頂堆定義,這里要把 9 除外,因為 9 已經是排好序的了

  3. 再將堆頂元素 8 與末尾元素 5 進行交換,得到第二大元素 8

  4. 后續過程,繼續進行調整、交換,如此反復進行,最終使得整個序列有序

    如果還不懂,就結合上面的動圖再看一遍。

思路總結

  1. 將無序序列構建成一個堆,根據升序降序需求選擇大頂堆小頂堆
  2. 將堆頂元素與末尾元素交換,將最大元素「沉」數組末端
  3. 重新調整結構,使其滿足堆定義,然后繼續交換數組第一個元素與末尾元素,反復執行調整、交換步驟,直到整個序列有序。

代碼實現

步驟推演

此推演,對照的是:堆排序步驟圖解中的第一步驟構造大頂堆,注意對照圖解進行理解代碼含義

    @Test
    public void processSortTest() {
        int arr[] = {4, 6, 8, 5, 9};
        processSort(arr);
    }

    private void processSort(int[] arr) {
        // 第一次:從最后一個非葉子節點開始調整,從左到右,從上到下進行調整。
        // 參與比較的元素是:6,5,9
        int i = 1;
        int temp = arr[i]; // 6
        //這里i表示三數堆頂的數
        int k = i * 2 + 1; // i 的左節點

        // 要將這三個數(堆),調整為一個大頂堆
        // 判斷 i 的左節點是否小於右節點
        if (arr[k] < arr[k + 1]) {
            k++; // 如果右邊的大,則將 k 變成最大的那一個
        }
        // 如果左右中最大的一個數,比 i 大。則調整它
        if (arr[k] > temp) {
            arr[i] = arr[k];
            arr[k] = temp;
        }
        System.out.println(Arrays.toString(arr)); // 4,9,8,5,6

        // 第二次調整:參與比較的元素是  4,9,5
        i = 0;
        temp = arr[i]; // 4
        k = i * 2 + 1;
        //如上一次
        if (arr[k] < arr[k + 1]) {
            k++; // 如果右邊的大,則將 k 變成最大的那一個
        }
        // 9 比 4 大,交換的是 9 和 4
        if (arr[k] > temp) {
            arr[i] = arr[k];
            arr[k] = temp;
        }
        System.out.println(Arrays.toString(arr)); // 9,4,8,5,6

        // 上面調整導致了,第一次的堆:4,5,6 的混亂。這里要對他進行重新調整
        i = 1;
        temp = arr[i]; // 4
        k = i * 2 + 1;
        if (arr[k] < arr[k + 1]) {
            k++; // 如果右邊的大,則將 k 變成最大的那一個
        }
        // 6 比 4 大,交換它
        if (arr[k] > temp) {
            arr[i] = arr[k];
            arr[k] = temp;
        }
        System.out.println(Arrays.toString(arr)); // 9,6,8,5,4
        // 到這里就構造成了一個大頂堆
    }

剩下的,是交換序列首尾元素,並繼續這個流程。

完整實現

這里想說的幾點注意事項(代碼實現的關鍵思路):

  1. 第一步構建初始堆:是自下向上,從左到右

    從下往上構建初始堆是為了:每一層的大頂堆一定比它的下一層左右兩節點大,也就是說,每一層都比下一層大。但是,左右是不要求大小的

  2. 第二步讓尾部元素與堆頂元素交換,最大值被放在數組末尾

  3. 第三步:是從上到下,從左到右

    因為第二步的原因,變化在堆頂,所以從堆頂開始調整;

    在初始堆的基礎上,一層一層往下調整,如果到了某一層而這層不需要調整的話,則退出當次的調整,也就是說已經構建好了一個新的大頂堆,因為初始堆的特點:每一層都比下一層大,可以直接退出

 @Test
    public void sortTest() {
        int[] arr = {4, 6, 8, 5, 9};
        sort(arr);
        int[] arr2 = {99, 4, 6, 8, 5, 9, -1, -2, 100};
        sort(arr2);
    }

    private void sort(int[] arr) {
        // =====  1. 構造初始堆
        // 從第一個非葉子節點開始調整
        // 4,9,8,5,6
        //  adjustHeap(arr, arr.length / 2 - 1, arr.length);

        // 循環調整
        // 從第一個非葉子節點開始調整,自下向上
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            adjustHeap(arr, i, arr.length);
        }
        // 第一輪調整了 3 個堆后:結果為:9,6,8,5,4
       

        // 2. 將堆頂元素與末尾元素進行交換,然后再重新調整
        int temp = 0;
        for (int j = arr.length - 1; j > 0; j--) {
            temp = arr[j];  // j 是末尾元素
            arr[j] = arr[0];
            arr[0] = temp;
            // 這里是從第一個節點開始: 不是構建初始堆了,而是重構
            // 如果
            adjustHeap(arr, 0, j);//j是要調整的數組長度,每次減 1,這里需要注意了!!!
        }
        System.out.println(Arrays.toString(arr));
    }

    /**
     * 調整堆
     * 
     * @param arr	 數組
     * @param i      非葉子節點,以此節點為基礎,將它、它的左、右,調整為一個大頂堆
     * @param length 需要構建成大頂堆的數組大小,除了構建初始堆的時候length=數組大小,其他時候的每一次重構堆都會減小 1
     */
    private void adjustHeap(int[] arr, int i, int length) {
        // 難點是將當前的數組首尾互換之后,影響到了它后面節點堆大小混亂,如何繼續對影響后的堆進行調整
        // 所以第一步構造初始堆中:是一個額外循環的 從低向上 調整的
        //    第三步數組首尾交換后的堆重構中:就是 從上到下調整的,這個很重要,一定要明白
        //結合上面sort()方法進行理解
        
        //需要調整的三數堆的堆頂元素
        int temp = arr[i];
        // 從傳入節點的左節點開始處理
        for (int k = i * 2 + 1; k < length; k = k * 2 + 1) {
            // 要將這三個數(堆),調整為一個大頂堆
            // k+1 < length : 當調整長度為 2 時,也就是數組的前兩個元素,其實它沒有第三個節點了,就不能走這個判定,通俗點說就是防止越界
            if (k + 1 < length && arr[k] < arr[k + 1]) {
                k++; // 如果右邊的大,則將 k 變成最大的那一個
            }
            // 如果左右中最大的一個數,比 i 大。則調整它
            if (arr[k] > temp) {//這里是跟temp比較,所以,在執行過程中,它並沒有發生變化,帶着這個想法思路去想,就會懂最后那一步了
                arr[i] = arr[k];
                i = k; // i 記錄被調整后的索引。這里為什么這樣做,看下面的這句arr[i] = temp;以及上面arr[i] = arr[k];代碼想一下
            } else {
                break;
                // 由於初始堆,就已經是大頂堆了,每個子堆的頂,都是比他的左右兩個大的
                // 當這里沒有進行調整的話,那么就可以直接退出了
                // 如果上面進行了調整。那么在初始堆之后,每次都是從 0 節點開始 自左到右,自上而下調整的
                //    就會一層一層的往下進行調整
            }
        }
        arr[i] = temp;//這里很重要!!!!
    }

測試信息

[4, 5, 6, 8, 9]
[-2, -1, 4, 5, 6, 8, 9, 99, 100]

性能測試

    /**
     * 大量數據排序時間測試
     */
    @Test
    public void bulkDataSort() {
        int max = 800000;
//        int max = 8;
        int[] arr = new int[max];
        for (int i = 0; i < max; i++) {
            arr[i] = (int) (Math.random() * max);
        }
        if (arr.length < 10) {
            System.out.println("原始數組:" + Arrays.toString(arr));
        }
        Instant startTime = Instant.now();
        sort(arr);
        if (arr.length < 10) {
            System.out.println("排序后:" + Arrays.toString(arr));
        }
        Instant endTime = Instant.now();
        System.out.println("共耗時:" + Duration.between(startTime, endTime).toMillis() + " 毫秒");
    }

多次測試輸出

共耗時:163 毫秒
共耗時:211 毫秒
共耗時:165 毫秒

可以看到他的速度非常快,在我的機器上,800 萬數據 160 毫秒左右 。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM