排序(五):歸併排序法

一. 基本思想
    歸併排序的思想是將數組分成兩部分,分別進行排序,然後歸併起來。把長度爲n的輸入序列分成兩個長度爲n/2的子序列;對這兩個子序列分別採用歸併排序;將兩個排序好的子序列合併成一個最終的排序序列。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

二. 代碼實現

  • 版本一
package com.hong.sort;

import java.util.Arrays;

/**
 * 歸併排序 O(Nlog(N))
 * 需要將當前的數組拷貝一份出來,因此相比之前的排序,需要更多的存儲空間
 */
public class MergeSort {

    /**
     * 將arr[l...mid]和arr[mid+1...r]兩部分進行歸併
     *
     * @param arr
     * @param l   左半部元素起始點
     * @param mid 左半部元素終點
     * @param r   右半部元素終點
     */
    private static void merge(int[] arr, int l, int mid, int r) {

        int[] aux = Arrays.copyOfRange(arr, l, r + 1);

        // 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
        int i = l, j = mid + 1;
        for (int k = l; k <= r; k++) {

            if (i > mid) {  // 如果左半部分元素已經全部處理完畢
                arr[k] = aux[j - l];
                j++;
            } else if (j > r) {   // 如果右半部分元素已經全部處理完畢
                arr[k] = aux[i - l];
                i++;
            } else if (aux[i - l] < aux[j - l]) {  // 左半部分所指元素 < 右半部分所指元素
                arr[k] = aux[i - l];
                i++;
            } else {  // 左半部分所指元素 >= 右半部分所指元素
                arr[k] = aux[j - l];
                j++;
            }
        }
    }

    /**
     * 遞歸使用歸併排序,對arr[l...r]的範圍進行排序
     *
     * @param arr
     * @param l
     * @param r
     */
    public static void sort(int[] arr, int l, int r) {
        if (l >= r) {
            return;
        }

       // int mid = (l + r) / 2;
        int mid = l + (r-l)/2;
        sort(arr, l, mid);
        sort(arr, mid + 1, r);
        merge(arr, l, mid, r);
    }

    public static void sort(int[] arr) {
        int n = arr.length;
        // [0,n-1],這裏注意區間的開閉
        sort(arr, 0, n - 1);
    } 
}
  • 版本二
package com.hong.sort;

import java.util.Arrays;

/**
 * 歸併排序優化
 */
public class MergeSort2 {

    public static void sort(int[] arr) {
        sort(arr, 0, arr.length - 1);
    }

    public static void sort(int[] arr, int left, int right) {
        /**
         * 優化2:
         *對於小規模數組, 使用插入排序
         */
        if (right - left <= 15){
            InsertionSort.sort(arr,left,right);
            return;
        }

       // int mid = (left + right) / 2;
        int mid = left + (right - left)/2;
        sort(arr, left, mid);
        sort(arr, mid + 1, right);
        /**
         * 優化1
         *   對於arr[mid] <= arr[mid+1]的情況,不進行merge
         *   對於近乎有序的數組非常有效,但是對於一般情況,有一定的性能損失
         */
        if (arr[mid] > arr[mid+1]){
            merge(arr, left, mid, right);
        }
    }

    public static void merge(int[] arr, int l, int mid, int r) {
        int[] aux = Arrays.copyOfRange(arr, l, r + 1);
        int i = l;
        int j = mid + 1;
        for (int k = l; k <= r; k++) {
            if (i > mid) {
                arr[k] = aux[j - l];
                j++;
            } else if (j > r) {
                arr[k] = aux[i - l];
                i++;
            } else if (aux[i - l] < aux[j - l]) {
                arr[k] = aux[i - l];
                i++;
            } else {
                arr[k] = aux[j - l];
                j++;
            }
        }
    }
}
public class InsertionSort {
    /**
     * 對arr[l...r]的區間使用InsertionSort排序
     * @param arr
     * @param l
     * @param r
     */
    public static void sort(int[] arr, int l, int r) {
        for (int i = l + 1; i <= r; i++) {
            int e = arr[i];
            int j = i;
            for (; j > l && arr[j - 1] > e; j--) {
                arr[j] = arr[j - 1];
            }
            arr[j] = e;
        }
    }
}

上面的兩個版本都是使用遞歸實現自頂向下的歸併排序。

  • 版本三
package com.hong.sort;

import java.util.Arrays;

/**
 * <br>自底向上的歸併排序</br>
 * 之前的歸併排序中是自頂向下,擴散式逐步拆分成小的組,排好序後,再逐步向上收斂整合成一個最終有序的序列
 *
 */
public class MergeSort3 {

    public static void merge(int[] arr, int l, int mid, int r) {
        int[] aux = Arrays.copyOfRange(arr, l, r + 1);
        int i = l;
        int j = mid + 1;
        for (int k = l; k <= r; k++) {
            if (i > mid) {
                arr[k] = aux[j - l];
                j++;
            } else if (j > r) {
                arr[k] = aux[i - l];
                i++;
            } else if (aux[i - l] < aux[j - l]) {
                arr[k] = aux[i - l];
                i++;
            } else {
                arr[k] = aux[j - l];
                j++;
            }
        }
    }

    /**
     * Merge Sort Bottom Up 無優化版本
     *
     * @param arr
     */
    public static void sort0(int[] arr) {
        int n = arr.length;

        // sz 表示對需要進行merge的元素個數進行遍歷
        // 第一輪 看 1個元素。之後看2個元素。。。
      for (int sz = 1; sz < n; sz *= 2) {
            // 每一輪在歸併的過程中,起始元素位置
            for (int i = 0; i < n - sz; i += sz + sz) {
                // 對 arr[i...i+sz-1] 和 arr[i+sz...i+2*sz-1] 進行歸併,注意越界問題
                merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, n - 1));
            }
        }
    }

    /**
     * Merge Sort Button Up優化
     *
     * @param arr
     */
    public static void sort(int[] arr) {
        int n = arr.length;

        // 對於小數組, 使用插入排序優化
        for (int i = 0; i < n; i += 16) {
            InsertionSort.sort(arr, i, Math.min(i + 15, n - 1));
        }

        for (int sz = 16; sz < n; sz += sz) {
            for (int i = 0; i < n - sz; i += sz + sz) {
                // 對於arr[mid] <= arr[mid+1]的情況,不進行merge
                if (arr[i + sz - 1] > arr[i + sz]) {
                    merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, n - 1));
                }
            }
        }
    }
}

三. 歸併排序在鏈表中的應用
LeetCode 148. 排序鏈表。
Merge Sort Bottom Up :使用 nlog(n) 的複雜度爲鏈表排序

package com.hong.leetcode;

import com.hong.linked.ListNode;

/**
 * Created by wanghong
 * Date 2019-05-10 19:18
 * Description:148. 排序鏈表
 */
public class Solution148 {

    /**
     * 自頂向下
     * 參考:Sort List——經典(鏈表中的歸併排序) https://www.cnblogs.com/qiaozhoulin/p/4585401.html
     *
     * 歸併排序法:在動手之前一直覺得空間複雜度爲常量不太可能,因爲原來使用歸併時,都是 O(N)的,
     * 需要複製出相等的空間來進行賦值歸併。對於鏈表,實際上是可以實現常數空間佔用的(鏈表的歸併
     * 排序不需要額外的空間)。利用歸併的思想,遞歸地將當前鏈表分爲兩段,然後merge,分兩段的方
     * 法是使用 fast-slow 法,用兩個指針,一個每次走兩步,一個走一步,知道快的走到了末尾,然後
     * 慢的所在位置就是中間位置,這樣就分成了兩段。merge時,把兩段頭部節點值比較,用一個 p 指向
     * 較小的,且記錄第一個節點,然後 兩段的頭一步一步向後走,p也一直向後走,總是指向較小節點,
     * 直至其中一個頭爲NULL,處理剩下的元素。最後返回記錄的頭即可。
     *
     * 主要考察3個知識點,
     * 知識點1:歸併排序的整體思想
     * 知識點2:找到一個鏈表的中間節點的方法
     * 知識點3:合併兩個已排好序的鏈表爲一個新的有序鏈表
     *
     * 一切的前提就是搞清爲啥歸併排序可以排序鏈表,原因如下
     * 包括選擇、插入、堆排序、快排等等都需要隨機讀取進行元素的交換,
     * 這正是鏈表所缺乏的,歸併排序是爲數不多不需要隨機讀取的排序算法
     */
    public ListNode sortList(ListNode head) {
        return head == null ? null : mergeSort(head);
    }

    private ListNode mergeSort(ListNode head) {
        if (head.next == null) {
            return head;
        }
        // p 最後表示中間節點,pre 最後表示 中間節點的前一個節點
        ListNode p = head, q = head, pre = null;
        while (q != null && q.next != null) {
            pre = p;
            p = p.next;
            q = q.next.next;
        }
        // 將整個鏈表一分爲二
        pre.next = null;
        ListNode l = mergeSort(head);
        ListNode r = mergeSort(p);
        return merge(l, r);
    }

    ListNode merge(ListNode l, ListNode r) {
        // 啞鏈表頭。臨時創建的一個鏈表頭,把邊界情況和普通情況做統一處理
        ListNode dummyHead = new ListNode(0);
        ListNode cur = dummyHead;
        while (l != null && r != null) {
            if (l.val <= r.val) {
                cur.next = l;
                cur = cur.next;
                l = l.next;
            } else {
                cur.next = r;
                cur = cur.next;
                r = r.next;
            }
        }
        if (l != null) {
            cur.next = l;
        }
        if (r != null) {
            cur.next = r;
        }
        return dummyHead.next;
    }

    /****************************************************/
    /**
     *要求空間複雜度是 O(1),因此不能使用遞歸。因此這裏使用 bottom-to-up 的算法來解決
     * bottom-to-up 的歸併思路是這樣的:先兩個兩個的 merge,完成一趟後,再 4 個4個的 merge,直到結束。舉個簡單的例子:[4,3,1,7,8,9,2,11,5,6].
     *
     * step=1: (3->4)->(1->7)->(8->9)->(2->11)->(5->6)
     * step=2: (1->3->4->7)->(2->8->9->11)->(5->6)
     * step=4: (1->2->3->4->7->8->9->11)->5->6
     * step=8: (1->2->3->4->5->6->7->8->9->11)
     * 鏈表裏操作最難掌握的應該就是各種斷鏈啊,然後再掛接啊。在這裏,我們主要用到鏈表操作的兩個技術:
     *
     * merge(l1, l2),雙路歸併,我相信這個操作大家已經非常熟練的,就不做介紹了。
     * cut(l, n),可能有些同學沒有聽說過,它其實就是一種 split 操作,即斷鏈操作。不過我感覺使用 cut 更準確一些,它表示,將鏈表 l 切掉前 n 個節點,並返回後半部分的鏈表頭。
     * 額外再補充一個 dummyHead 大法,已經講過無數次了,仔細體會吧。
     * @param head
     * @return
     */
    public ListNode sortList2(ListNode head) {

        return null;
    }

    public static void main(String[] args) {
        Solution148 s = new Solution148();
        int[] nums = {4,2,1,3};
        ListNode head = new ListNode(nums);
        System.out.println(head);
        head = s.sortList(head);
        System.out.println(head);
    }

}
package com.hong.linked;

/**
 * @author wanghong
 * @date 2019/04/08 22:57
 **/
public class ListNode {
    public int val;
    public ListNode next;

    public ListNode(int x) {
        val = x;
    }

    // 鏈表節點的構造函數
    // 使用arr爲參數,創建一個鏈表,當前的ListNode爲鏈表頭結點
    public ListNode(int[] arr){

        if(arr == null || arr.length == 0)
            throw new IllegalArgumentException("arr can not be empty");

        this.val = arr[0];
        ListNode cur = this;
        for(int i = 1 ; i < arr.length ; i ++){
            cur.next = new ListNode(arr[i]);
            cur = cur.next;
        }
    }

    // 以當前節點爲頭結點的鏈表信息字符串
    @Override
    public String toString(){

        StringBuilder s = new StringBuilder();
        ListNode cur = this;
        while(cur != null){
            s.append(cur.val + "->");
            cur = cur.next;
        }
        s.append("NULL");
        return s.toString();
    }

    public int getVal() {
        return val;
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章