學點算法(三)——數組歸併排序

今天來學習歸併排序算法。

歸併算法的核心思想是分而治之,就是將大問題轉化爲小問題,在解決小問題的基礎上,再去解決大問題。將這句話套用到排序中,就是將一個大的待排序區間分爲小的待排序區間,對小的排序區間進行排序,然後再去解決大區間的排序,而對小區間進行排序的時候可以繼續使用該方法,將小的待排序區間分爲小小的待排序區間… …依次往復。最終將排序區間分到只有一個元素,這個時候,因爲一個元素已經就是排好序的,無需繼續切分了,然後我們再依照此結果去解決大區間的排序。

假設我們現在有[53, 89, 32, 45, 67, 2, 32, 89, 65, 54]這麼一個數組,我們要對它進行歸併排序(從小到大排序),整體的過程如下圖所示:
歸併排序算法完整過程
整個算法分爲兩大階段,分割階段歸併階段

分割階段

  1. [53, 89, 32, 45, 67, 2, 32, 89, 65, 54]分爲[53, 89, 32, 45, 67][2, 32, 89, 65, 54]
  2. [53, 89, 32, 45, 67]分爲[53, 89][32, 45, 67][2, 32, 89, 65, 54]分爲[2, 32][89, 65, 54]
  3. … …
  4. 數組分割完畢,所有小數組依次爲[53][89][32][45][67][2][32][89][65][54]

歸併階段

  1. [53][89]歸併爲[53, 89][32][45]歸併爲[32, 45][2][32]歸併爲[2, 32][65][54]歸併爲[54, 65](這一步中,[67][89]沒有歸併,因爲在最後一步分割過程中,它們被單獨分開了)。
  2. [32, 45][67]歸併爲[32, 45, 67][89][54, 65]歸併爲[54, 65, 89]
  3. [53, 89][32, 45, 67]歸併爲[32, 45, 53, 67, 89][2, 32][54, 65, 89]歸併爲[2, 32, 54, 65, 89]
  4. [32, 45, 53, 67, 89][2, 32, 54, 65, 89]歸併爲[2, 32, 32, 45, 53, 54, 65, 67, 89, 89]其中兩個32和兩個89,在歸併的過程中保留它們的原始順序)。

整個分而治之的過程我們已經清楚了,可還有一個問題沒有解決,就是具體應該怎麼去歸併呢,即如何將兩個排序子數組(或子區間)合併爲大的排序好的數組(或區間)呢?

我們可以先舉個簡單的例子:現在有[2][1]兩個數組,我們如何把它們合併爲[1, 2]整個數組呢?很簡單,我們首先會把這兩個元素取出來,對比一下,取出21,我們一對比,發現1小於2, 所以我們在結果數組中先放入1,然後再放入2。可以發現,我們就是將兩個子數組中的元素取出來比較了一下,哪個小就把哪個先放入結果數組中。

從上面的例子中我們可以得到大概的思路就是,針對兩個有序的子數組(或子區間),我們可以從頭依次取兩個子數組(或子區間)的首元素(因爲從小到大排序後首元素肯定最小),然後作對比,把小的元素放入結果數組中,並且這個元素在下次選取的時候剔除,下一個元素也應用同樣的方法得到,放入結果數組中,依次進行,直到兩個數組的元素都取完爲止,如果發現其中一個子數組(或子區間)率先取完,就直接將另外一個子數組(或子區間)中剩下的元素全部放入結果數組中即可。具體步驟描述如下:

  1. 判斷兩個子數組(或子區間)是否含有剩餘元素,如果都有剩餘元素,進入第2步;如果只有一個有剩餘元素,進入第5步;如果沒有,則退出。
  2. 取出左子數組(或左子區間)的首個元素和右子數組(或右子區間)的首個元素。
  3. 兩個元素對比,將小的元素放入結果數組,並且從對應數組中剔除該元素。
  4. 回到第1步(上一步選中的元素已被剔除)。
  5. 將剩餘元素直接全部放入結果數組中,退出(因爲元素全部移動完畢)。

其中,剔除這一步在代碼實現中可看成索引的移動。

上述這個過程我們取[53, 89][32, 45, 67]這兩個子數組的合併來描述一下,如圖所示:
歸併

  1. 取出左子數組中的首個元素53和右子數組中的首個元素32,兩個作對比,發現32 < 53,所以我們將32放入結果數組:
    在這裏插入圖片描述
  2. 取出左子數組中的首個元素53和右子數組中的首個元素45,兩個作對比,發現45 < 53,所以我們將45放入結果數組:
    在這裏插入圖片描述
  3. 取出左子數組中的首個元素53和右子數組中的首個元素67,兩個作對比,發現53 < 67,所以我們將53放入結果數組:
    在這裏插入圖片描述
  4. 取出左子數組中的首個元素89和右子數組中的首個元素67,兩個作對比,發現67 < 89,所以我們將67放入結果數組:
    在這裏插入圖片描述
  5. 此時我們發現只有左子數組存在元素,所以直接將左子數組的剩下所有元素,此時只有89放入結果數組:
    在這裏插入圖片描述
  6. 至此,所有元素移動完畢,退出。

通過以上分析,我們可以知道整個歸併排序算法總體上分爲一個整體的大邏輯(分而治之)和一個局部的小邏輯(歸併),在大邏輯(分而治之,將整個數組切分,並在確認子數組有序後歸併)的基礎上,結合使用小邏輯(歸併,將兩個有序子數組歸併爲一個大的有序數組)即可實現對整個數組的排序。

最終代碼實現如下:

/**
 * 數組的歸併排序算法
 *
 * @param nums 數組
 * @param lo 區間的lo索引(包含)
 * @param hi 區間的hi索引(不包含)
 */
public static void mergeSort(int[] nums, int lo, int hi) {
    // 數組爲null則直接返回
    if (nums == null) {
        return;
    }
    // 索引檢查
    if (lo < 0 || nums.length <= lo) {
        throw new IllegalArgumentException("lo索引必須大於0並且小於數組長度,數組長度:" + nums.length);
    }
    if (hi < 0 || nums.length < hi) {
        throw new IllegalArgumentException("hi索引必須大於0並且小於等於數組長度,數組長度:" + nums.length);
    }
    if (hi <= lo) {
        // lo索引必須小於hi索引(等於也不行,因爲區間是左閉右開,如果等於,區間內元素數量就爲0了)
        throw new IllegalArgumentException("lo索引必須小於hi索引");
    }
    if (lo + 1 >= hi) {
        // 區間元素個數最多爲1
        // 無需排序
        return;
    }
    int mid = (lo + hi) / 2;
    // 對左子區間排序
    mergeSort(nums, lo, mid);
    // 對右子區間排序
    mergeSort(nums, mid, hi);
    // 對兩個排序好的子區間歸併,得到一個整體有序的區間
    merge(nums, lo, mid, hi);
}

public static void merge(int[] nums, int lo, int mid, int hi) {
    // 這裏不用檢查索引,調用方已經決定了索引是有效的
    // 結果區間和右子區間使用原有數組
    // 左子區間使用臨時數組(因爲結果區間可能會覆蓋左子區間的元素,所以需要開闢新數組保存)
    int leftLen = mid - lo;
    int[] left = new int[leftLen];
    System.arraycopy(nums, lo, left, 0, leftLen);
    // 左子區間索引
    int leftIdx = 0;
    // 右子區間索引
    int rightIdx = mid;
    // 結果區間索引
    int resultIdx = lo;
    while (true) {
        if (leftIdx < leftLen && rightIdx < hi) {
            // 兩個子區間都存在元素
            // 取兩個子區間的有效首元素對比
            if (left[leftIdx] <= nums[rightIdx]) {
                // 左子區間首元素小於右子區間首元素
                // 將左子區間首元素放到結果位置,同時更新索引位置
                nums[resultIdx++] = left[leftIdx++];
            } else {
                // 右子區間首元素小於左子區間首元素
                // 將右子區間首元素放到結果位置,同時更新索引位置
                nums[resultIdx++] = nums[rightIdx++];
            }
        } else {
            if (leftIdx < leftLen) {
                // 左子區間還有剩餘元素
                // 直接將左區間所有元素一起移動到結果位置
                System.arraycopy(left, leftIdx, nums, resultIdx, leftLen - leftIdx);
            } else {
                // 右子區間還有剩餘元素
                // 因爲經過上一次判斷,左子區間和右子區間只會有一個存在剩餘元素
                // 直接將右區間所有元素一起移動到結果位置
                System.arraycopy(nums, rightIdx, nums, resultIdx, hi - rightIdx);
            }
            // 全部元素移動完畢,退出
            break;
        }
    }
}

測試代碼如下:

List<Integer> numList = IntStream.range(0, 10).boxed().collect(Collectors.toList());
for (int i = 1; i <= 5; i++) {
    System.out.println("================第" + i + "次================");
    Collections.shuffle(numList);
    int[] nums = new int[numList.size()];
    for (int j = 0; j < nums.length; j++) {
        nums[j] = numList.get(j);
    }
    System.out.println("排序前:" + Arrays.toString(nums));
    mergeSort(nums, 0, numList.size());
    System.out.println("排序後:" + Arrays.toString(nums));
}

運行結果如下:

================1================
排序前:[8, 4, 1, 6, 7, 0, 5, 9, 2, 3]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================2================
排序前:[2, 5, 6, 7, 9, 4, 3, 1, 0, 8]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================3================
排序前:[2, 0, 5, 6, 7, 3, 4, 9, 8, 1]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================4================
排序前:[4, 0, 3, 8, 1, 5, 9, 7, 2, 6]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================5================
排序前:[7, 9, 8, 2, 0, 5, 6, 3, 4, 1]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

測試代碼中5次隨機將數組打亂,然後運行我們的歸併排序算法,均得到有序結果,符合我們的預期。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章