數(shù)據(jù)結(jié)構(gòu)與算法--歸并排序
歸并排序
歸并排序基于一種稱為“歸并”的簡(jiǎn)單操作。比如考試可能會(huì)分年級(jí)排名和班級(jí)排名,那么這個(gè)年級(jí)排名可能是合并了各個(gè)班級(jí)的排名后對(duì)其再排名得到——每個(gè)班級(jí)會(huì)上報(bào)本班學(xué)生成績(jī)排名情況,上面的人根據(jù)各班提交的排名情況匯總成一個(gè)總排名。這就是歸并在生活中的例子。那么歸并排序,說(shuō)得抽象些,其實(shí)就是將兩個(gè)已經(jīng)有序的數(shù)組歸并成一個(gè)更大的有序數(shù)組,這里注意要求在歸并之前兩個(gè)子數(shù)組已經(jīng)有序。于是,對(duì)整個(gè)大數(shù)組排序,可以先(遞歸地)將其分成兩半分別排序,然后將結(jié)果歸并起來(lái)。
實(shí)現(xiàn)歸并排序的一種簡(jiǎn)單方法是:將兩個(gè)不同的有序數(shù)組歸并到第三個(gè)數(shù)組中去,因此該方法實(shí)現(xiàn)的歸并排序需要額外空間,且所需空間的大小和待排序數(shù)組的長(zhǎng)度N成正比。下面來(lái)看,通過(guò)怎樣的比較才能將兩個(gè)數(shù)組的元素有序地歸并到一個(gè)數(shù)組中。
private static void merge(Comparable[] a, Comparable[] aux, int low, int mid, int high) {
int i = low; // 左半數(shù)組的指針 [0, mid]
int j = mid + 1; // 右半數(shù)組的指針 [mid +1, high]
// 將待歸并的數(shù)組元素全歸并到一個(gè)新數(shù)組中
for (int k = low; k <= high; k++) {
aux[k] = a[k];
}
for (int k = low; k <= high; k++) {
// 左半數(shù)組指針超出,被取完。于是取右半數(shù)組中的元素
if (i > mid) {
a[k] = aux[j++];
// 右半數(shù)組被取完,取左半數(shù)組中的元素
} else if (j > high) {
a[k] = aux[i++];
// 已滿足i <= mid && j <= high
// 右半數(shù)組的元素小,就取右半數(shù)組中元素
} else if (less(aux[j], aux[i])) {
a[k] = aux[j++];
// 已滿足i <= mid && j <= high
// 左半數(shù)組元素小或者相等,取左半數(shù)組中的元素,相等時(shí)取左邊保證了排序穩(wěn)定性
} else {
a[k] = aux[i++];
}
}
}
該方法將數(shù)組區(qū)間[low, high]
之間的所有元素都復(fù)制到了一個(gè)新的數(shù)組aux中,然后在歸并回原數(shù)組a中。為此進(jìn)行了4個(gè)判斷:
- 左半數(shù)組被取盡 --> 取右半數(shù)組中的元素;
- 右半數(shù)組被取盡 --> 取左半數(shù)組中的元素;
- 左半數(shù)組的當(dāng)前元素小于右半數(shù)組的當(dāng)前元素 --> 取左半數(shù)組的元素;
- 左半數(shù)組的當(dāng)前元素不小于右半數(shù)組中的元素 --> 取右半數(shù)組中的元素,因此當(dāng)左右數(shù)組中當(dāng)前元素等值時(shí)會(huì)去右半數(shù)組中的元素。
注意條件1、2和條件3、4的順序不可顛倒,因?yàn)槿绻麠l件1、2不通過(guò),執(zhí)行條件3、4時(shí)候就保證了i <= mid && j <= mid
,使得左右半數(shù)組的訪問(wèn)都不會(huì)出現(xiàn)下標(biāo)越界。如果條件3、4放在前面判斷,隨著j的自增,可能就下標(biāo)越界了。
如下圖演示了歸并的過(guò)程,依據(jù)上面的條件,不斷從右邊的aux[]
中取處元素順序放回原數(shù)組a中,達(dá)到將兩個(gè)數(shù)組歸并(由原數(shù)組分割成的左右數(shù)組),從而實(shí)現(xiàn)了排序。
自頂向下的歸并排序
為了將小數(shù)組歸并成大數(shù)組,需要保證小數(shù)組是有序的。于是我們可以利用的遞歸的方法,將數(shù)組分成左右兩半分別排序,左右數(shù)組有序后就能將結(jié)果歸并到一起了。根據(jù)描述,可寫(xiě)出如下代碼。
package Chap9;
public class MergeSort {
/* private static void merge...
merge方法見(jiàn)上面
*/
private static void sort(Comparable[] a, Comparable[] aux, int low, int high) {
// high = low說(shuō)明數(shù)組被劃分到只有一個(gè)元素,無(wú)需排序和歸并直接返回
if (high <= low) {
return;
}
int mid = low + (high - low) / 2;
sort(a, aux, low, mid);
sort(a, aux, mid + 1, high);
merge(a, aux, low, mid, high);
}
public static void sort(Comparable[] a) {
Comparable[] aux = new Comparable[a.length];
sort(a, aux, 0, a.length - 1);
}
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
public static boolean isSorted(Comparable[] a) {
for (int i = 0; i < a.length - 1; i++) {
if (less(a[i + 1], a[i])) {
return false;
}
}
return true;
}
public static String toString(Comparable[] a) {
if (a.length == 0) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append("[");
for (int i = 0; i < a.length; i++) {
sb.append(a[i]);
if (i == a.length - 1) {
return sb.append("]").toString();
} else {
sb.append(", ");
}
}
return sb.toString();
}
public static void main(String[] args) {
Integer[] a = {9, 1, 5, 8, 3, 7, 4, 6, 2};
MergeSort.sort(a);
System.out.println(MergeSort.toString(a));
System.out.println(MergeSort.isSorted(a));
}
}
輔助數(shù)組aux是sort方法的局部變量,這表明每對(duì)一個(gè)序列調(diào)用sort方法都會(huì)生成一個(gè)aux數(shù)組。不能將aux設(shè)置成 類靜態(tài)成員static Comparable[] aux
,因?yàn)槿绻卸鄠€(gè)程序用這個(gè)類進(jìn)行排序,它們就共享了該輔助數(shù)組aux,在多線程中很容易出問(wèn)題。將aux定義在merge方法里也不合適,這意味著每次歸并都會(huì)生成一個(gè)數(shù)組,無(wú)疑是浪費(fèi)。
下圖顯示了歸并過(guò)程
第一次歸并是a[0]和a[1],第二次歸并是a[2]和a[3],第三次歸并是a[0..3],然后是a[4]和a[5]...以此類推。你可能詫異為什么是這樣的歸并順序,看下圖遞歸方法調(diào)用的順序就清楚了。
我們還可以將這種“先對(duì)左半數(shù)組進(jìn)行排序,再將右半數(shù)組進(jìn)行排序”的歸并方法用樹(shù)來(lái)表示,如下
該樹(shù)的高度為lg N
,設(shè)lg N = n
,對(duì)于[0, n]
中任意k,自頂向下的第k層有2^k
個(gè)子數(shù)組,每個(gè)數(shù)組的長(zhǎng)度為2^(n-k)
,歸并最多需要2^(n-k)
次比較,所以第k層需要2^k * 2^(n-k) = 2^n
次比較,總共n層需要n * 2^n
即Nlg N
次比較。故時(shí)間復(fù)雜度為O(Nlg N)
歸并排序可以處理大規(guī)模的數(shù)組,但是主要缺點(diǎn)是引入的輔助數(shù)組所需的額外空間與數(shù)組大小N成正比。
改進(jìn)
對(duì)小規(guī)模數(shù)組換用插入排序
用不同的算法處理小規(guī)模問(wèn)題能改進(jìn)大多數(shù)遞歸算法的性能,因?yàn)檫f歸會(huì)使得小規(guī)模問(wèn)題的方法調(diào)用過(guò)于頻繁。對(duì)排序來(lái)說(shuō),我們知道插入排序非常簡(jiǎn)單而且適合小規(guī)模數(shù)組,因此在歸并排序中如果需要處理的數(shù)組比較小,比如數(shù)組長(zhǎng)度小只有十幾,就可以用插入排序代替歸并過(guò)程。
測(cè)試數(shù)組是否有序
我們知道在歸并之前,左半數(shù)組和右半數(shù)組已經(jīng)分別有序,如果左半數(shù)組的最后一個(gè)元素(下標(biāo)為mid)比右半數(shù)組的小于等于第一個(gè)數(shù)組(下標(biāo)為mid + 1),說(shuō)明該數(shù)組本身就有序,無(wú)需再調(diào)用merge方法。
基于以上兩點(diǎn),對(duì)自頂而下的歸并排序優(yōu)化如下
private static void sort(Comparable[] a, Comparable[] aux, int low, int high) {
// high <= low + 15說(shuō)明當(dāng)數(shù)組很小時(shí)直接換用插入排序,當(dāng)數(shù)組長(zhǎng)度不超過(guò)16時(shí)都使用插入排序
if (high <= low + 15) {
InsertSort.sort(a);
return;
}
int mid = low + (high - low) / 2;
sort(a, aux, low, mid);
sort(a, aux, mid + 1, high);
// a[mid] <= a[mid + 1]已經(jīng)有序,跳過(guò)歸并操作
if (a[mid].compareTo(a[mid + 1]) > 0) {
merge(a, aux, low, mid, high);
}
}
在原來(lái)的基礎(chǔ)上只是加了簡(jiǎn)單幾行,就可以稍微提升一點(diǎn)性能!歸并排序的可視化過(guò)程見(jiàn)下圖
自底向上的歸并排序
遞歸實(shí)現(xiàn)的歸并是算法設(shè)計(jì)中分治思想體現(xiàn),我們將一個(gè)大問(wèn)題分割成小問(wèn)題分別解決,然后用所有的小問(wèn)題的答案解決整個(gè)大問(wèn)題。實(shí)現(xiàn)歸并排序的另一種思路是直接從小數(shù)組開(kāi)始?xì)w并(因此無(wú)需使用遞歸的方法將大數(shù)組分成左右子數(shù)組),然后再成對(duì)歸并得到的子數(shù)組,如此這般,直到將整個(gè)數(shù)組歸并到一起。
首先我們進(jìn)行兩兩歸并,然后四四歸并、八八歸并,一直下去。最后一次歸并的第二個(gè)子數(shù)組可能比第一個(gè)子數(shù)組要 小,即便如此merge方法也能很好地處理,因此不用在意。
public class MergeBU {
public static void sort(Comparable[] a) {
Comparable[] aux = new Comparable[a.length];
// sz = 1, 2, 4, 8...
for (int sz = 1; sz < a.length; sz = sz + sz) {
// sz = 1: low= 0, 2, 4, 6, 8, 10...
// sz = 2: low= 0, 4, 8, 12, 16...
// sz = 4: low= 0, 8, 16, 24...
for (int low = 0; low < a.length-sz; low += (sz + sz)) {
// sz = 1: 歸并子數(shù)組 (0,0,1) (2,2,3) (4,4,5)...
// sz = 2: 歸并子數(shù)組 (0,1,3) (4,5,7) (8,9,11)...
// sz = 4: 歸并子數(shù)組 (0,3,7) (8,11,15) (16,19,23)...
// 可由歸納法得到mid = low + sz -1; high = low + 2sz -1
// 最后一個(gè)子數(shù)組可能比sz小,所以通過(guò)low + 2sz -1計(jì)算high可能比原數(shù)組還要大,因此和a.length - 1取最小
merge(a, aux, low, low + sz - 1, Math.min(low + sz + sz - 1, a.length - 1));
}
}
}
}
merge方法直接使用自頂向下的歸并排序中的改進(jìn)的merge方法。注釋中寫(xiě)出了sz和low的遞增過(guò)程,外循環(huán)中sz=1表示兩兩歸并,sz=2表示四四歸并,以此類推,每次都成倍增長(zhǎng)...內(nèi)循環(huán)中l(wèi)ow每次增長(zhǎng)sz + sz
,實(shí)際上是直接跳到了下一個(gè)子數(shù)組,邊界條件low < a.length - sz
比較難懂,如果是low < a.length
就比較好理解,表示的是最后一個(gè)子數(shù)組的開(kāi)始下標(biāo),后者當(dāng)然能得出正確結(jié)果,但是相比前者在處理最后一個(gè)子數(shù)組時(shí)可能有多余的歸并操作。
low < a.length - sz
終止條件,說(shuō)明頂多最后一個(gè)sz長(zhǎng)度的子數(shù)組沒(méi)有進(jìn)行歸并操作就跳出循環(huán)了,這種情況發(fā)生在倒數(shù)第二個(gè)子數(shù)組的low剛好等于a.length - 3sz
時(shí),該子數(shù)組的范圍是[a.length - 3sz, a.length - sz -1]
,下一次low自增low += (sz + sz)
得到low = a.length - sz
剛好不滿足條件,于是后面長(zhǎng)度為sz的子數(shù)組沒(méi)有歸并。如下圖所示。
其他情況下,沒(méi)有被歸并的子數(shù)組長(zhǎng)度都比上述情況要小。如下兩幅圖所示,
再來(lái)看,最后一個(gè)長(zhǎng)度為小于等于sz的數(shù)組沒(méi)有被歸并影響結(jié)果嗎?由外層for循環(huán)的sz = sz + sz
可知,sz正好是上一輪中被歸并的子數(shù)組長(zhǎng)度(現(xiàn)在的sz是上一輪中sz的兩倍,而被歸并的子數(shù)組長(zhǎng)度2sz),因此本輪中最后這長(zhǎng)度為sz的子數(shù)組,在上一輪中就已經(jīng)歸并過(guò)了,條件low < a.length - sz
正好規(guī)避了多余的歸并操作。
至于merge方法中,mid = low + sz -1
,以及high = low + sz + sz -1
都可以通過(guò)數(shù)學(xué)歸納法得到,有時(shí)候最后一個(gè)子數(shù)組可能比sz小,所以通過(guò)low + sz + sz -1計(jì)算high可能超出原數(shù)組長(zhǎng)度,因此和a.length - 1取最小以防止下標(biāo)越界。
下面是自底向上的歸并排序軌跡圖以及可視化過(guò)程。
和自頂而下的歸并排序相比,可以發(fā)現(xiàn)他們的歸并順序并不一樣。
歸并排序在最壞情況下需要比較~Nlg N
次,這已經(jīng)是所有基于比較的排序算法中最好的效率了,但是歸并排序需要額外空間,這不是最優(yōu)的。
歸并排序中先對(duì)子數(shù)組兩兩歸并,由于merge方法代碼中else分支包含了左半數(shù)組元素和右半數(shù)組元素相等的情況,此時(shí)取的是左半數(shù)組的元素,這保證了等值元素的相對(duì)位置不會(huì)發(fā)生改變,隨著歸并數(shù)組的增大,這種關(guān)系依然成立。所以歸并排序是穩(wěn)定的排序算法。
by @sunhaiyu
2017.10.29