java 排序算法之歸并排序
簡(jiǎn)單介紹
歸并排序(merge sort)是利用 歸并 的思想實(shí)現(xiàn)的排序方法,該算法采用經(jīng)典的分治(divide-and-conquer)策略 :
- 分(divide):將問題分成一些小的問題,然后遞歸求解
- 治(conquer):將分的階段得到的各答案「修補(bǔ)」在一起
即:分而治之
該算法是采用分治法(Divide and Conquer)的一個(gè)非常典型的應(yīng)用。將已有序的子序列合并,得到完全有序的序列;即先使每個(gè)子序列有序,再使子序列段間有序。若將兩個(gè)有序表合并成一個(gè)有序表,稱為二路歸并。
基本思想

- 分:分的過程只是為了分解
- 治:分組完成后,開始對(duì)每組進(jìn)行排序,然后合并成一個(gè)有序序列,直到最后將所有分組合并成一個(gè)有序序列
可以看到這種結(jié)構(gòu)很像一棵完全二叉樹,本文的歸并排序我們采用遞歸實(shí)現(xiàn)(也可采用迭代的方式實(shí)現(xiàn))。分階段可以理解為就是遞歸拆分子序列的過程。
注:這些數(shù)從始至終都在一個(gè)數(shù)組里(只是抽象的想想成兩個(gè)數(shù)組),除了排序時(shí)會(huì)把要排序的數(shù)copy到一個(gè)臨時(shí)數(shù)組里面,這里如果不懂后面看了代碼后,再返回來思考就懂了。一定要思考?。。?!
下面的動(dòng)圖可以很直觀的看到整個(gè)算法實(shí)現(xiàn)的過程,初體驗(yàn)一下吧,后面的代碼可以結(jié)合上面的圖,和這個(gè)動(dòng)圖,來整理一下邏輯

多個(gè)數(shù)排序動(dòng)圖:

思路分析
對(duì)于分階段相對(duì)較簡(jiǎn)單,下面著重來對(duì)治階段進(jìn)行分析。
合并相鄰有序子序列分析:下圖以 歸并排序的最后一次合并 為例來分析,即對(duì)應(yīng)上圖的 [4,5,7,8] 和 [1,2,3,6] 兩個(gè)已經(jīng)有序的子序列合并為最終序列 [1,2,3,4,5,6,7,8],下圖展示了實(shí)現(xiàn)步驟


如圖所示:這是最后一次的合并 操作,是兩個(gè)有序序列合并為最終的有序序列。
- 1.從有序序列開始挨個(gè)比較,這里比較 4 和 1,1 < 4,那么 1 進(jìn)入臨時(shí)數(shù)組
temp中,并且將自己的指針右移動(dòng)一位 - 2.由于圖上綠色部分上一次 4 大于 1,指針并未移動(dòng),然后 4 和 2 對(duì)比,2 < 4 , 2 進(jìn)入到臨時(shí)數(shù)組中,并且將自己的指針右移動(dòng)一位
- 3. ...
- 4.如果某一組已經(jīng)全部進(jìn)入了臨時(shí)數(shù)組,那么剩余一組的剩余有序序列直接追加到臨時(shí)數(shù)組中
- 5.最后,將 temp 內(nèi)容拷貝到原數(shù)組中去,完成排序
代碼實(shí)現(xiàn)
先實(shí)現(xiàn)合并方法,這個(gè)是該算法中最重要的,你也看可以直接看后面的完整算法代碼,再返回來思考,這個(gè)都隨你。此次是因?yàn)?分 的步驟需要用到 合 的這個(gè),所以我這里就先放 合并的代碼了。
/**
*
* 最難的是合并,所以可以先完成合并的方法,此方法請(qǐng)參考 基本思想 和 思路分析中的圖解來完成
* 動(dòng)腦筋
*
*
*
* @param arr 要排序的原始數(shù)組
* @param left 因?yàn)槭呛喜?,所以要得到左右兩邊的的?shù)組信息,這個(gè)是左側(cè)數(shù)組的第一個(gè)索引值
* @param mid 因?yàn)槭且粋€(gè)數(shù)組,標(biāo)識(shí)是第一個(gè)數(shù)組的最后一個(gè)索引,即 mid+1 是右側(cè)數(shù)組的第一個(gè)索引,即中間索引
* @param right 右側(cè)數(shù)組的結(jié)束索引,右邊數(shù)組的最后一個(gè)數(shù)
* @param temp 臨時(shí)空間,臨時(shí)數(shù)組
*/
public void merge(int[] arr, int left, int mid, int right, int[] temp) {
// 定時(shí)臨時(shí)變量,用來遍歷數(shù)組比較
int l = left; // 左邊有序數(shù)組的初始索引
int r = mid + 1; // 右邊有序數(shù)組的初始索引
int t = 0; // temp 數(shù)組中當(dāng)前最后一個(gè)有效數(shù)據(jù)的索引
// 1. 按思路先將兩個(gè)數(shù)組(有序的)有序的合并到 temp 中
// 因?yàn)槭呛喜蓚€(gè)數(shù)組,所以需要兩邊的數(shù)組都還有值的時(shí)候才能進(jìn)行 比較合并
// 若其中一個(gè)數(shù)組的值遍歷完了(取完了),那么就跳出該循環(huán),把另一個(gè)數(shù)組所剩的值,直接copy進(jìn)臨時(shí)數(shù)組即可
while (l <= mid && r <= right) {
// 如果左邊的比右邊的小,則將左邊的該元素填充到 temp 中
// 并移動(dòng) t++,l++,好繼續(xù)下一個(gè)
if (arr[l] < arr[r]) {
temp[t] = arr[l];
t++;//移動(dòng) temp 臨時(shí)數(shù)組中當(dāng)前最后一個(gè)有效數(shù)據(jù)的索引
l++; //左邊原始數(shù)組的索引移動(dòng)
}
// 否則則將右邊的移動(dòng)到 temp 中
else {
temp[t] = arr[r];
t++;//移動(dòng) temp 臨時(shí)數(shù)組中當(dāng)前最后一個(gè)有效數(shù)據(jù)的索引
r++;//右邊原始數(shù)組的索引移動(dòng)
}
}
// 2. 如果有任意一邊的數(shù)組還有值,則依序?qū)⑹S鄶?shù)據(jù)填充到 temp 中
// 如果左側(cè)還有值
while (l <= mid) {
temp[t] = arr[l];
t++;
l++;
}
// 如果右側(cè)還有值
while (r <= right) {
temp[t] = arr[r];
t++;
r++;
}
// 3. 將 temp 數(shù)組,拷貝到原始數(shù)組
// 注意:只拷貝當(dāng)前temp有效數(shù)據(jù)到對(duì)應(yīng)的原始數(shù)據(jù)中,通俗點(diǎn)說,就是原始數(shù)組中要排序的數(shù)據(jù),通過temp排成了有序的,然后將temp中的有序數(shù)據(jù)將原始數(shù)組中原來要排序的那部分覆蓋了就行了
int tempL = left; // 從左邊開始拷貝,左邊第一個(gè)值的索引
t = 0; // temp 中的有效值索引,有效值邊界可以通過 right 判定,因?yàn)楹喜蓚€(gè)數(shù)組到 temp 中,邊界就是 right ,這里自己好好想想
/*
* 對(duì)于 8,4,5,7,1,3,6,2 這個(gè)數(shù)組,
* 從棧頂開始合并:8,4 | 5,7 1,3 | 6,2
* 先左遞歸的話:
* 第一次:8,4 合并:tempL=0,right=1
* 第二次:5,7 合并:tempL=2,right=3
* 第三次:4,8 | 5,7 進(jìn)行合并,tempL=0,right=3
* 直到左遞歸完成,得到左側(cè)一個(gè)有序的序列:4,5,7,8 然后再開始遞歸右邊那個(gè)無序的
* 最后回到棧底分解成兩個(gè)數(shù)組的地方,將兩個(gè)數(shù)組合并成一個(gè)有序數(shù)組
* 這里真的得自己想了,我只能這么說了。
*/
System.out.println("tempL=" + tempL + "; right=" + right);
while (tempL <= right) {
arr[tempL] = temp[t];
tempL++;
t++;
}
}
需要注意的是:這個(gè)圖一定要看明白:
- 1.一直分解,到棧頂首次合并時(shí),是兩個(gè)數(shù)字,也就是說,左側(cè)數(shù)組只有一個(gè)數(shù)字,右側(cè)數(shù)組只有一個(gè)數(shù)字
- 2.左側(cè)數(shù)組只有一個(gè)數(shù)字時(shí),
l = 0,r = 1,那么 mid = 0,邊界判定時(shí)要用 l <= mid && r <= right,否則就會(huì)跳過對(duì)比合并了

完整代碼如下
@Test
public void sortTest() {
int[] arr = new int[]{8, 4, 5, 7, 1, 3, 6, 2};
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
System.out.println("排序后:" + Arrays.toString(arr));
}
/**
* 分 和 合并
*/
public void mergeSort(int[] arr, int left, int right, int[] temp) {
//確保兩個(gè)數(shù)組中分別都存在至少一個(gè)數(shù)字
if (left < right) {
int mid = (left + right) / 2;
// 先分解左側(cè)
mergeSort(arr, left, mid, temp);
// 再分解右側(cè)
mergeSort(arr, mid + 1, right, temp);
// 最后合并
// 由于是遞歸,合并這里一定是棧頂?shù)南葓?zhí)行(兩邊數(shù)組各只有一個(gè)數(shù)時(shí))
// 第一次:left = 0,right=1
// 第二次:left = 2,right=3
// 第三次:left = 0,right=3
// System.out.println("left=" + left + ";right=" + right);
merge(arr, left, mid, right, temp);
}
}
/**
*
* 最難的是合并,所以可以先完成合并的方法,此方法請(qǐng)參考 基本思想 和 思路分析中的圖解來完成
* 動(dòng)腦筋
*
*
*
* @param arr 要排序的原始數(shù)組
* @param left 因?yàn)槭呛喜?,所以要得到左右兩邊的的?shù)組信息,這個(gè)是左側(cè)數(shù)組的第一個(gè)索引值
* @param mid 因?yàn)槭且粋€(gè)數(shù)組,標(biāo)識(shí)是第一個(gè)數(shù)組的最后一個(gè)索引,即 mid+1 是右側(cè)數(shù)組的第一個(gè)索引,即中間索引
* @param right 右側(cè)數(shù)組的結(jié)束索引,右邊數(shù)組的最后一個(gè)數(shù)
* @param temp 臨時(shí)空間,臨時(shí)數(shù)組
*/
public void merge(int[] arr, int left, int mid, int right, int[] temp) {
// 定時(shí)臨時(shí)變量,用來遍歷數(shù)組比較
int l = left; // 左邊有序數(shù)組的初始索引
int r = mid + 1; // 右邊有序數(shù)組的初始索引
int t = 0; // temp 數(shù)組中當(dāng)前最后一個(gè)有效數(shù)據(jù)的索引
// 1. 按思路先將兩個(gè)數(shù)組(有序的)有序的合并到 temp 中
// 因?yàn)槭呛喜蓚€(gè)數(shù)組,所以需要兩邊的數(shù)組都還有值的時(shí)候才能進(jìn)行 比較合并
// 若其中一個(gè)數(shù)組的值遍歷完了(取完了),那么就跳出該循環(huán),把另一個(gè)數(shù)組所剩的值,直接copy進(jìn)臨時(shí)數(shù)組即可
while (l <= mid && r <= right) {
// 如果左邊的比右邊的小,則將左邊的該元素填充到 temp 中
// 并移動(dòng) t++,l++,好繼續(xù)下一個(gè)
if (arr[l] < arr[r]) {
temp[t] = arr[l];
t++;//移動(dòng) temp 臨時(shí)數(shù)組中當(dāng)前最后一個(gè)有效數(shù)據(jù)的索引
l++; //左邊原始數(shù)組的索引移動(dòng)
}
// 否則則將右邊的移動(dòng)到 temp 中
else {
temp[t] = arr[r];
t++;//移動(dòng) temp 臨時(shí)數(shù)組中當(dāng)前最后一個(gè)有效數(shù)據(jù)的索引
r++;//右邊原始數(shù)組的索引移動(dòng)
}
}
// 2. 如果有任意一邊的數(shù)組還有值,則依序?qū)⑹S鄶?shù)據(jù)填充到 temp 中
// 如果左側(cè)還有值
while (l <= mid) {
temp[t] = arr[l];
t++;
l++;
}
// 如果右側(cè)還有值
while (r <= right) {
temp[t] = arr[r];
t++;
r++;
}
// 3. 將 temp 數(shù)組,拷貝到原始數(shù)組
// 注意:只拷貝當(dāng)前temp有效數(shù)據(jù)到對(duì)應(yīng)的原始數(shù)據(jù)中,通俗點(diǎn)說,就是原始數(shù)組中要排序的數(shù)據(jù),通過temp排成了有序的,然后將temp中的有序數(shù)據(jù)將原始數(shù)組中原來要排序的那部分覆蓋了就行了
int tempL = left; // 從左邊開始拷貝,左邊第一個(gè)值的索引
t = 0; // temp 中的有效值索引,有效值邊界可以通過 right 判定,因?yàn)楹喜蓚€(gè)數(shù)組到 temp 中,邊界就是 right ,這里自己好好想想
/*
* 對(duì)于 8,4,5,7,1,3,6,2 這個(gè)數(shù)組,
* 從棧頂開始合并:8,4 | 5,7 1,3 | 6,2
* 先左遞歸的話:
* 第一次:8,4 合并:tempL=0,right=1
* 第二次:5,7 合并:tempL=2,right=3
* 第三次:4,8 | 5,7 進(jìn)行合并,tempL=0,right=3
* 直到左遞歸完成,得到左側(cè)一個(gè)有序的序列:4,5,7,8 然后再開始遞歸右邊那個(gè)無序的
* 最后回到棧底分解成兩個(gè)數(shù)組的地方,將兩個(gè)數(shù)組合并成一個(gè)有序數(shù)組
* 這里真的得自己想了,我只能這么說了。
*/
System.out.println("tempL=" + tempL + "; right=" + right);
while (tempL <= right) {
arr[tempL] = temp[t];
tempL++;
t++;
}
}
測(cè)試輸出
tempL=0; right=1
tempL=2; right=3
tempL=0; right=3
tempL=4; right=5
tempL=6; right=7
tempL=4; right=7
tempL=0; right=7
排序后:[1, 2, 3, 4, 5, 6, 7, 8]
從這里也可以看到,先左遞歸的話,可以看到最開始合并的索引是 0,1 也就是在棧頂開始首次遞歸時(shí):兩個(gè)數(shù)組中分別只有一個(gè)數(shù)字。
最后一次合并:則是回到了最初棧底開始分解的方法,將兩個(gè)數(shù)組 0到7 進(jìn)行排序到臨時(shí)數(shù)組 temp ,最后將temp中的數(shù)據(jù)再?gòu)?code> 0到7 覆蓋到原始數(shù)組中。完成了排序 。
8 個(gè)數(shù)字,會(huì)進(jìn)行 7 次 合并
結(jié)合上面動(dòng)圖進(jìn)行思考。
對(duì)代碼的一些改進(jìn)
根據(jù)上述所講的基本思想和思路分析,對(duì)代碼進(jìn)行了一些改進(jìn)修改,減小代碼的臃腫。
public class MyMergeSortTest {
@Test
public void sortTest() {
int[] arr = new int[]{8, 4, 5, 7, 1, 3, 6, 2};
mergeSort(arr);
System.out.println("排序后:" + Arrays.toString(arr));
}
public void mergeSort(int arr[]) {
int[] temp = new int[arr.length];
doMergeSort(arr, 0, arr.length - 1, temp);
}
/**
* 分治 與 合并
*
* @param arr
* @param left
* @param right
* @param temp
*/
private void doMergeSort(int[] arr, int left, int right, int[] temp) {
// 當(dāng)還可以分解時(shí),就做分解操作
if (left < right) {
int mid = (left + right) / 2;
// 先左遞歸分解
doMergeSort(arr, left, mid, temp);
// 再右遞歸分解
doMergeSort(arr, mid + 1, right, temp);
// 左遞歸分解到棧頂時(shí),其實(shí)也是分為左右數(shù)組
// 左右都到棧頂時(shí),開始合并:
// 第一次:合并的是 0,1 的索引,分解到最后的時(shí)候,其實(shí)只有一個(gè)數(shù)為一組,所以第一次合并是合并兩個(gè)數(shù)字
merge(arr, left, mid, right, temp);
}
}
/**
* 開始合并,每次合并都是兩組,第一次合并是兩個(gè)數(shù)字,左右一組都只有一個(gè)數(shù)字
*
* @param arr
* @param left
* @param mid
* @param right
* @param temp
*/
private void merge(int[] arr, int left, int mid, int right, int[] temp) {
// 1. 按照規(guī)則,將 temp 數(shù)組填充
// 2. 當(dāng)任意一邊填充完成后,剩余未填充的依次填充到 temp 數(shù)組中
// 3. 將 temp 數(shù)組的有效內(nèi)容,拷貝會(huì)原數(shù)組,也就是將這次排序好的數(shù)組覆蓋回原來排序的原數(shù)組中
int l = left; // 左側(cè)數(shù)組初始索引
int r = mid + 1; // 右側(cè)數(shù)組初始索引
int t = 0; // 當(dāng)前 temp 中有效數(shù)據(jù)的最后一個(gè)元素索引
// 1. 按照規(guī)則,將 temp 數(shù)組填充
// 當(dāng)兩邊都還有數(shù)字可比較的時(shí)候,進(jìn)行比較,然后填充 temp 數(shù)組
// 只要任意一邊沒有數(shù)值可用時(shí),就僅需到下一步驟
while (l <= mid && r <= right) {
// 當(dāng)左邊比右邊小時(shí),則填充到 temp 數(shù)組中
if (arr[l] < arr[r]) {
// 賦值完成后,t 和 l 都需要 +1,往后移動(dòng)一個(gè)位置
// t++ 是先把 t 進(jìn)行賦值,再進(jìn)行 t+1 操作
temp[t++] = arr[l++];
} else {
// 當(dāng)不滿足時(shí),則說明 右側(cè)數(shù)字比左側(cè)的小,進(jìn)行右側(cè)的填充
temp[t++] = arr[r++];
}
}
// 2. 有可能有其中一邊會(huì)有剩余數(shù)字未填充到 temp 中,進(jìn)行收尾處理
while (l <= mid) {
temp[t++] = arr[l++];
}
while (r <= right) {
temp[t++] = arr[r++];
}
// 3. 將這個(gè)有序數(shù)組,覆蓋會(huì)原始排序的數(shù)組中
t = 0;
int tempL = left; // 從左側(cè)開始,到右側(cè)結(jié)束,就是這一次要合并的兩組數(shù)據(jù)
while (tempL <= right) {
arr[tempL++] = temp[t++];
}
}
}
大數(shù)據(jù)量耗時(shí)測(cè)試
/**
* 大量數(shù)據(jù)排序時(shí)間測(cè)試
*/
@Test
public void bulkDataSort() {
int max = 80000;
// max = 8;
int[] arr = new int[max];
for (int i = 0; i < max; i++) {
arr[i] = (int) (Math.random() * 80000);
}
if (arr.length < 10) {
System.out.println("原始數(shù)組:" + Arrays.toString(arr));
}
Instant startTime = Instant.now();
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
if (arr.length < 10) {
System.out.println("排序后:" + Arrays.toString(arr));
}
Instant endTime = Instant.now();
System.out.println("共耗時(shí):" + Duration.between(startTime, endTime).toMillis() + " 毫秒");
}
多次測(cè)試輸出
共耗時(shí):26 毫秒
共耗時(shí):37 毫秒
共耗時(shí):30 毫秒
共耗時(shí):30 毫秒
復(fù)雜度
歸并排序比較占用內(nèi)存,但卻是一種效率高且穩(wěn)定的算法。
改進(jìn)歸并排序在歸并時(shí)先判斷前段序列的最大值與后段序列最小值的關(guān)系再確定是否進(jìn)行復(fù)制比較。如果前段序列的最大值小于等于后段序列最小值,則說明序列可以直接形成一段有序序列不需要再歸并,反之則需要。所以在序列本身有序的情況下時(shí)間復(fù)雜度可以降至O(n)。
TimSort可以說是歸并排序的終極優(yōu)化版本,主要思想就是檢測(cè)序列中的天然有序子段(若檢測(cè)到嚴(yán)格降序子段則翻轉(zhuǎn)序列為升序子段)。在最好情況下無論升序還是降序都可以使時(shí)間復(fù)雜度降至為O(n),具有很強(qiáng)的自適應(yīng)性。
最好時(shí)間復(fù)雜度 最壞時(shí)間復(fù)雜度 平均時(shí)間復(fù)雜度 空間復(fù)雜度 穩(wěn)定性 傳統(tǒng)歸并排序 O(nlogn) O(nlogn) O(nlogn) T(n) 穩(wěn)定 改進(jìn)歸并排序 [1] O(n) O(nlogn) O(nlogn) T(n) 穩(wěn)定 TimSort O(n) O(nlogn) O(nlogn) T(n) 穩(wěn)定
我個(gè)人感覺歸并排序的邏輯相比快速排序來說更為容易理解,而且時(shí)間復(fù)雜度和快排一樣。關(guān)于快排的有關(guān)講解請(qǐng)看java 排序算法之快速排序。
到此這篇關(guān)于java 排序算法之歸并排序的文章就介紹到這了,更多相關(guān)java 歸并排序內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
關(guān)于Spring的@Autowired依賴注入常見錯(cuò)誤的總結(jié)
有時(shí)我們會(huì)使用@Autowired自動(dòng)注入,同時(shí)也存在注入到集合、數(shù)組等復(fù)雜類型的場(chǎng)景。這都是方便寫 bug 的場(chǎng)景,本篇文章帶你了解Spring @Autowired依賴注入的坑2021-09-09
探索Java中的equals()和hashCode()方法_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
這篇文章主要介紹了探索Java中的equals()和hashCode()方法的相關(guān)資料,需要的朋友可以參考下2017-05-05
Java String不可變性實(shí)現(xiàn)原理解析
這篇文章主要介紹了Java String不可變性實(shí)現(xiàn)原理解析,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-04-04
springboot全局字符編碼設(shè)置方式(解決亂碼問題)
這篇文章主要介紹了springboot全局字符編碼設(shè)置方式(解決亂碼問題),具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-12-12
java實(shí)現(xiàn)清理DNS Cache的方法
這篇文章主要介紹了java實(shí)現(xiàn)清理DNS Cache的方法,分析了幾種常用的清理方法,并給出了反射清理的完整實(shí)例,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-01-01
Spring boot如何通過@Scheduled實(shí)現(xiàn)定時(shí)任務(wù)及多線程配置
這篇文章主要介紹了Spring boot如何通過@Scheduled實(shí)現(xiàn)定時(shí)任務(wù)及多線程配置,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-12-12

