一. 寫在前面
要學習算法,“排序”是一個回避不了的重要話題,在分析完并查集算法和常用數據結構之后,今天我們終于可以走近排序算法這個大家族了。計算機科學發展到今天,已經有了成百上千種讓你眼花繚亂,多的數不清的排序算法,而且還會有更新更厲害的算法尚未問世,它們都是人類無窮智慧的結晶,有太多太有意思的內容等待我們去思考,去品味。作為一篇入門級的學習筆記,我們不會在這里展開討論,但是,我們將從“排序算法”這個百寶袋中,抓取幾個經典的,膾炙人口的算法,作為這次我們討論的主題。我們會先聊聊三種最基本的算法:選擇,插入和Shell排序;然后我們走入分治思想的世界,聊聊歸并排序;然后我們將登上世界之巔,來看看20世紀最偉大的算法之一——快速排序,了解一下這幾十年來人們為了研究她都做了哪些巨大貢獻;最后,我們將認識一個新朋友,一個為排序而生的數據結構——優先級隊列,并以一些排序算法的實際應用作為本算法分析筆記的結束,在讀完本篇文章之后,相信你會獲得對排序算法有一個基礎的了解。
當然,本篇文章介紹的內容都是我在學習Princeton大學Sedgewick教授的Coursera上公開課“Algorithms”時的一些心得,文章中的一些圖示,例子和代碼,均來自于老教授的課件和英文原版教科書,所有這一切的知識和成果,都是他老人家辛苦整理的智慧結晶。感謝Sedgewick老師,感謝這位可敬的老人帶給我算法學習中的不少樂趣。
1.1 排序算法怎么玩
排序是一種將某元素的集合按照某種邏輯順序進行排列的過程。我們之所以要排序,是因為它能滿足我們的某種需要,特別是對于有強迫癥的人來說,排列整齊總是比雜亂無章要好的。最簡單的例子是,在前面介紹并查集算法時,我們曾經聊到過二叉搜索算法,它能很快地從集合中查找元素,但前提是該集合內的元素是已排序的。實際生活中的例子還有很多:比如人員名單在中國通常按筆畫數排序,在英美國家則是字母順序;學校里老師要按照分數對考試成績進行排序,看看前十名都有哪些人;銀行要按照資產情況對客戶信息進行排序,存款最多的用戶也許能發展成為該行的VIP客戶;超市要按照時間順序對交易記錄進行排序,以打印賬單等等。幾乎所有需要用計算機去處理相關事務的領域,你都會看到排序算法的身影,它們往往是重要算法的關鍵一環。
1.2 排序算法的抽象設計
應用越廣泛的東西,遇到的問題也就越多。如果我們只對簡單的元素進行排序,那一點都不難,比如對數字進行排序,對字符串進行排序。但實際生活中我們往往要面對的是復雜的情況和復雜的對象。首先,不是所有的元素集合都能排序,比如我們愛玩的“石頭剪刀布”游戲,石頭干掉剪刀,剪刀干掉布,然后布又干掉石頭,你把哪個放在前面都對,又都不對,無法排序;其次,對一個元素集合我們可能會按照多種標準進行排序,比如一條學生成績記錄可能包含姓名,學號,科目和分數等等,那我既可以按分數高低排序選出優秀學生,也可以按照姓名排序進行點名,更可以按照科目分數排序找出某一科的佼佼者。這些都會在實際生活中遇到,如何處理?
對于第一個問題,我們要先弄清楚的是究竟什么樣的元素集合是可以排序的。答案是:如果你能在這個元素集合上找到一個“全序”(Total Order)關系,那么它就是可以排序的。全序的定義是:1)自反(Reflexive),對所有元素e,e=e都成立;2)反對稱(Antisymmetric),對所有元素v和w,v < w則w > v,w = v則v = w;3)傳遞(Transitive),對所有的v,w和x,v <= w且w <= x,那么v <= x。“石頭剪刀布”顯然就不滿足,因為雖然石頭能干掉剪刀,剪刀能干掉布,但石頭并不能干掉布,而是被布給干掉了,不滿足傳遞性。不過在實際編程工作中我們也不用太在意,知道有這么回事就好,我們只需要通過某種方式告訴排序算法如何判斷兩個元素誰大誰小就可以了。
那么怎樣告訴排序算法兩個元素誰大誰小呢?我們的方法是基于“回調”機制實現,而各種不同的編程語言在“回調”的基礎上建立了自己的處理方法:C語言使用函數指針,C++通過定義函對象重載函數調用操作符實現,Python語言通過FP的方式實現,Java語言和Go語言則是通過“接口”來實現。“面向接口編程”是一種重要的思想,在設計這種通用算法的時候就特別有用,這些通用的算法通過“回調”來處理具體的對象,而不需要知道對象的細節,這便是依賴倒置原則:細節依賴于抽象,而抽象并不依賴于細節。
在Java中,只要我們的類滿足Comparable接口,通過實現compareTo()函數就能告訴排序算法兩個元素誰大誰小。例如一個實現了Comparable接口的Date類如下所示,這樣我們就可以用排序算法對Date進行按日期的排序排序。compareTo()函數返回一個整型來表示大小關系:正數表示大于,負數表示小于,0則表示等于。
public class Date implements Comparable<Date> {
/* ... */
public int compareTo(Date that) {
if (this.year < that.year) return -1;
if (this.year > that.year) return +1;
if (this.month < that.month) return -1;
if (this.month > that.month) return +1;
if (this.day < that.day) return -1;
if (this.day > that.day) return +1;
return 0;
}
/* ... */
}
除此之外,我們還要實現兩個輔助函數:less和exch,less函數用于對元素的比較進行進一步“包裝”——因為compareTo返回的是整型值,而我們需要一個返回布爾值的函數;exch函數則用于交換兩個元素,這些都是排序算法中所需要的。這樣,我們在實現排序算法時就通過這些函數,以一種統一的形式去操作數據結構,而不去關心它們是怎么比較大小或者怎么交換元素的。
private static boolean less(Comparable v, Comparable w) {
return (v.compareTo(w) < 0);
}
private static void exch(int[] a, int i, int j) {
int swap = a[i];
a[i] = a[j];
a[j] = swap;
}
那如果我們要對同一記錄進行多種形式的排序又該怎么做呢?這就要用到Java的另一個更高級的接口——Comparator。這個接口只包含一個函數compare(),它同樣通過返回一個整型值來表示大小關系:正數表示大于,負數表示小于,而0表示相等。比如我們有一個表示商業事務的類Transaction,包含客戶姓名,日期和資產,我們需要對商業事務的記錄按照姓名、日期和資產進行排序,那么我們就可以在Transaction中 實現三個滿足Comparator接口的類:WhoOrder,WhenOrder以及HowMuchOrder。
import java.util.Arrays;
import java.util.Comparator;
public class Transaction implements Comparable<Transaction> {
private final String who; // customer
private final Date when; // date
private final double amount; // amount
/* ... */
/** * Compares two transactions by customer name. */
public static class WhoOrder implements Comparator<Transaction> {
public int compare(Transaction v, Transaction w) {
return v.who.compareTo(w.who);
}
}
/** * Compares two transactions by date. */
public static class WhenOrder implements Comparator<Transaction> {
public int compare(Transaction v, Transaction w) {
return v.when.compareTo(w.when);
}
}
/** * Compares two transactions by amount. */
public static class HowMuchOrder implements Comparator<Transaction> {
public int compare(Transaction v, Transaction w) {
if (v.amount < w.amount) return -1;
else if (v.amount > w.amount) return +1;
else return 0;
}
}
public static void main(String[] args) {
Transaction[] a = new Transaction[4];
a[0] = new Transaction("Turing 6/17/1990 644.08");
a[1] = new Transaction("Tarjan 3/26/2002 4121.85");
a[2] = new Transaction("Knuth 6/14/1999 288.34");
a[3] = new Transaction("Dijkstra 8/22/2007 2678.40");
StdOut.println("Unsorted");
for (int i = 0; i < a.length; i++)
StdOut.println(a[i]);
StdOut.println();
StdOut.println("Sort by date");
Arrays.sort(a, new Transaction.WhenOrder());
for (int i = 0; i < a.length; i++)
StdOut.println(a[i]);
StdOut.println();
StdOut.println("Sort by customer");
Arrays.sort(a, new Transaction.WhoOrder());
for (int i = 0; i < a.length; i++)
StdOut.println(a[i]);
StdOut.println();
StdOut.println("Sort by amount");
Arrays.sort(a, new Transaction.HowMuchOrder());
for (int i = 0; i < a.length; i++)
StdOut.println(a[i]);
StdOut.println();
}
}
相應地,less函數和exch函數也要做一些輕微的調整,如下所示。實際工作中,我們可以按照需求選擇Comparable或者Comparator接口來設計我們的類。好了,以上就是我們為研究各種排序算法搭好的一個基本“框架”,我們介紹了Java的兩個接口,介紹了回調機制以及“面向接口編程”的重要思想,下面,我們就來深入學習一下各種算法的思想及其實現吧。
// is v < w ?
private static boolean less(Comparator c, Object v, Object w) {
return (c.compare(v, w) < 0);
}
// exchange a[i] and a[j]
private static void exch(Object[] a, int i, int j) {
Object swap = a[i];
a[i] = a[j];
a[j] = swap;
}
二. 基礎排序算法
我們以選擇排序,插入排序和Shell排序為例,介紹三種最基本的排序算法。第一個要認識的就是選擇排序算法,選擇排序只能作為入門介紹,因為它糟糕的性能無法在實際生活中使用,而后兩種算法就不同了,它們在一些特殊情況和場景下會很有用,這個后面會有討論。
2.1 選擇排序
用一句話來描述選擇排序,就是把當前最小的元素放到它應該在的位置。算法會遍歷序列中的每一個位置i,然后在i的右邊選擇一個(當前的)最小值,把它放到位置i,把位置i上原先存在的元素交換出去。算法第一次運行時,會把最小的元素放在位置0,第二次運行時把第二小的元素放在位置1……這樣當遍歷完最后一個元素時,整個序列就排好序了,如圖2-1所示。
public class Selection {
// This class should not be instantiated.
private Selection() { }
public static void sort(Comparable[] a) {
int N = a.length;
for (int i = 0; i < N; i++)
{
int min = i;
for (int j = i+1; j < N; j++) {
if (less(a[j], a[min])) min = j;
}
exch(a, i, min);
assert isSorted(a, 0, i);
}
assert isSorted(a);
}
}
從上面的代碼我們可以分析它的性能,算法總共的比較次數為(N-1) + (N-2) + ... + 1 + 0 = N(N-1)/2,交換次數為N次,故性能為O(N^2)。而且選擇排序是一個“油鹽不進”的排序算法,隨便你給出什么樣的輸入序列——哪怕它已經是有序的——都需要平方時間才能完成排序,因此選擇排序就跟冒泡排序一樣,了解了解就好,沒有什么實際的用處。
2.2 插入排序
插入排序名字取得不好,它應該叫“撲克排序”,想想你斗地主的時候是怎么理牌的,你就知道插入排序的大致步驟了。在插入排序運行的過程中,我們總是假定位置i之前已經是有序的,我們的任務就是將位置i的元素放到合適的位置,就好比摸了一張新牌,要把這張新牌插入到合適的位置一樣。如圖2-2所示,我們手里已經有了三張排好序的牌,當我們再摸到梅花3時,因為它比這幾張牌都要小,所以我們最終將它插入到了最開始的位置。
public class Insertion {
// This class should not be instantiated.
private Insertion() { }
public static void sort(Comparable[] a) {
int N = a.length;
for (int i = 0; i < N; i++) {
for (int j = i; j > 0 && less(a[j], a[j-1]); j--) {
exch(a, j, j-1);
}
assert isSorted(a, 0, i);
}
assert isSorted(a);
}
}
最壞情況下(輸入序列逆序),待插入的元素要跟之前所有的元素相比較,因此需要N2/2次比較和交換;最好情況下(輸入序列已排序),待插入元素無需移動,且只比較一次,總共需要N-1次比較;平均情況下,插入排序大概需要N2/4次比較和交換,因此它是一個O(N^2)的算法,遇到很大的序列,排序時間會比較慢。
但如果你就此下結論,說插入排序是一個沒用的算法,那就太輕率了。插入排序有一些很有趣的性質,科學家們對插入排序更進一步的研究發現,插入排序對較小的序列很有效,而且對部分有序的序列效率很高。要理解部分有序,首先要認識一個“逆”的 概念,一個序列中的“逆”,是指序列中的逆序對,比如“A E E L M O T R X P S”中“T-R T-P T-S R-P X-P X-S”就是其中存在的6個逆。若一個序列元素有N個,則“部分有序”是指該序列的逆序數小于等于cN,其中c為常數。
如果一個序列是部分有序的,那么插入排序的運行效率將會是線性的,即O(N)時間內就能完成,為什么呢?仔細觀察插入排序的代碼你就會發現,每進行一次交換,序列的逆序數就會減一(因此插入排序可以用來計算一個序列的逆序數,歸并排序也能),因此交換的次數就等于逆序數,既然逆序數小于等于cN,那么交換的性能為O(N);關鍵在于比較的次數,首先,每個元素至少都要跟它前面的那個元素進行一次比較,所以一定有(N-1)次,其次,每發生一次交換就意味著有過一次比較,且比較的結果是該元素比它之前的那個元素小,因此總的比較次數一定是(N-1)再加上交換的次數,比較的性能仍然是O(N),所以在面對部分有序的序列時,插入排序能做到線性時間內完成。
這一事實導致了兩個有趣的結果。首先,你會發現插入排序總是跟歸并排序和快速排序算法玩“曖昧”,在歸并排序和快速排序將序列分解成一定規模的小數組之后,使用插入排序對這些小數組進行排序要比繼續分解要好,能夠節省一些開銷,如圖2-3和2-4所示。在1993年Bentley 和 McIlroy那篇著名的論文“Engineering a Sort Function”中,兩位大神給出了一種具有實際工程意義的快速排序實現,該算法在處理較小的數組時,就使用了插入排序。時至今日,該論文已經成為各種編程語言排序算法標準庫實現的必備參考,你如果有心去閱讀這些語言的源代碼,就能發現該論文的身影。比如Go語言sort包中快速排序的實現就借鑒了該論文的思想,如圖2-5和2-6所示。
另一個有趣的結果是,因為插入排序對部分有序的數組工作的很好,科學家們就挖空心思地鉆研如何將序列弄得部分有序,這誕生了另一個有趣的算法——Shell排序,你可以將Shell排序當成插入排序的一個變種,這也是我們接下來要分析的。
2.3 Shell排序
如上所述,Shell排序是對插入排序的一種改進。既然插入排序對部分有序的序列很有效,那么我們就要琢磨一下怎樣讓序列變得部分有序。Shell排序的思路是,與其像插入排序那樣挨個排序,還不如間隔h個元素進行排序,也就是每次排序向前跳h個位置,這樣序列雖然整體上看貌似無序,但每間隔h個元素的序列卻是交錯有序的,這種排序被稱為h-排序,而排序后的序列被稱為“h-有序的”,如圖2-7所示。Shell排序有個重要的概念,一個h-有序的序列在g-排序后仍然是h-有序的,那么如果我們以某種方式逐步縮小h直到h變為1,那么當進行h為1的那次排序時,序列已經部分有序,而且排序也退化為一般的插入排序,那么算法的執行效率也就有了提高。在一開始時,因為h很大,所以子序列很短,隨著算法的進行,h越來越小,子序列越來越長,整個序列部分有序的程度越來越高,執行插入排序的效率也就越來越高。那么h的跳數該怎么選擇呢?人們已經找到不少有效的計算公式,但一個簡單實用的“3X+1”即可滿足絕大部分的性能要求了。
public class Shell {
// This class should not be instantiated.
private Shell() { }
public static void sort(Comparable[] a) {
int N = a.length;
// 3x+1 increment sequence: 1, 4, 13, 40, 121, 364, 1093, ...
int h = 1;
while (h < N/3) h = 3*h + 1;
while (h >= 1)
{
// h-sort the array
for (int i = h; i < N; i++) {
for (int j = i; j >= h && less(a[j], a[j-h]); j -= h) {
exch(a, j, j-h);
}
}
h /= 3;
}
assert isSorted(a);
}
}
首先,我們讓h增大到大約N/3的位置,然后一邊對序列進行h排序,一邊減小h的值,每次減小到原來的1/3,這樣最后一次h的值就為1,有科學家研究,該算法最壞情況下的運行效率為O(N^3/2),算法演示如圖2-8所示。Shell排序是一個頗具神秘魅力的算法,因為對它的平均情況效率還沒有得出可用的結論。但這并不妨礙Shell排序成為一個實用的算法。除非遇到巨大的序列,Shell排序還是很快的,在嵌入式和硬件領域應用較為廣泛。
2.4 隨機洗牌算法
大多數時候,我們希望得到的信息是排列有序,讓人賞心悅目的,但有些時候我們卻希望信息是亂序的,是隨機的,是讓人猜不準下一步會得到什么的。比如你在網上開了一家虛擬賭場,讓大家都來你這里打牌斗地主,你就希望洗牌算法每次得到的結果都不一樣,否則每次拿到一樣的牌,這地主還怎么斗下去呢,這也不符合實際情況呀。所以我們的目標是:重新排列數組,使得得到的排列是均勻分布的。其中的一種辦法是,我們為數組中的每一個位置用滿足均勻分布的偽隨機數生成器產生一個隨機數,對隨機數進行排序,就能得到想要的結果(當然,舉一反三地想想,如果我們使用產生滿足其他概率分布的隨機數生成器,就能生成滿足其他性質的隨機序列),如圖2-9所示。
還有一種隨機洗牌算法更常用,是牛人Knuth他老人家發明的,叫做Knuth Shuffle,這種方法的基本思想就是:對于元素arr[i],用隨機挑選的另一個元素arr[r]與它進行互換,其中r是[0, i]或者[i, N-1]區間內隨機選出來的一個元素,如下所示。
public static void shuffle(Object[] a) {
int N = a.length;
for (int i = 0; i < N; i++) {
int r = StdRandom.uniform(i + 1);
exch(a, i, r);
}
}
public static void shuffle(int[] a) {
int N = a.length;
for (int i = 0; i < N; i++) {
int r = i + uniform(N-i); // between i and N-1
int temp = a[i];
a[i] = a[r];
a[r] = temp;
}
}
實際上,開發隨機數算法一定要慎之又慎,因為稍不注意就會出現一些很小的漏洞,如果在線撲克牌游戲中出現這樣那樣的一些漏洞,黑客就可以利用它推算出程序將要發什么牌,這有時候是災難性的,網上有一篇著名的博文當隨機不夠隨機:一個在線撲克游戲的教訓就介紹了上世紀90年代末國外一個很流行的在線撲克平臺出現過的嚴重錯誤,是隨機洗牌算法的一個很好的反面教材,提醒我們算法設計一定要小心,有興趣的可以看一看。
三. 算法名人堂——歸并排序
基本排序介紹完了,現在我們登堂入室,來認識一個家喻戶曉的著名算法——歸并排序。不像Shell排序,人們對歸并排序的性能可謂了如指掌,所以她被大量運用到各種計算系統中。Java就用她來排序各種對象,而C++和Python等編程語言則使用她來實現一種“穩定”的排序算法,我們對歸并排序的介紹,就從“穩定性”這個概念開始。
3.1 排序算法的穩定性
“穩定性”是在對記錄進行多種方式排序時通常要考慮的問題,如果記錄可以通過Key1排序,又可以通過Key2排序,那么在Key2排序之后,如果Key2相同的一眾記錄相對于Key1而言仍然是有序的,那么我們就說該排序算法是穩定排序,否則,就是不穩定的,圖3-1中所示的例子中,我們先對學生記錄按照姓名進行排序,然后又按照分區進行排序,結果第3區的學生不再是按姓名排序的,因此選擇排序并不是一個穩定的算法。在我們介紹過的算法中,只有插入排序是穩定的,因為它每次移動元素的跨度很小,不會跑到跟它一樣大的元素前面去。而選擇排序和Shell排序跨度都比較大,比如Shell排序一開始就每間隔h個元素進行排序,自然無法保證穩定性。
歸并排序不但效率最優,而且滿足穩定性,是一個優秀的算法。它的基本思想就是將序列一分為二,分別排序左半部分和右半部分,然后再將這兩部分歸并成一個有序的序列,其中對左半部分和右半部分的排序是遞歸的,仍然是一分為二然后歸并,如圖3-2所示。歸并排序就是一個很典型的“分治”算法思想的體現:某問題解決起來困難,那么我們就將該問題不斷拆分成眾多子問題,然后將子問題的解匯總成最終問題的解。“分治”算法不但高效且容易進行數學分析,這個后面會看到。
3.2 歸并排序中的歸并
如圖3-3所示,在歸并排序中,歸并是一個重要的組成部分,它的基本思想是:拷貝并使用一個同樣大小的輔助序列aux,用兩個索引分別指向已排序的子序列aux[lo..mid]和aux[mid+1..hi],同時遍歷兩個序列,比較遍歷到的元素,每次都將最小的元素放入arr,如果其中有哪個子序列歸并完了,那么就將另一子序列的元素一個一個拷進去。使用輔助數組aux意味著這種歸并排序的空間效率不高——每次都要使用額外的O(N)空間,其實歸并排序的版本不止一種,還有一些比較復雜的算法實現了真正的就地歸并。這里還可以學到一個新技能:斷言。通常每個算法的執行都會有一個前置狀態(Precondition),當滿足前置條件時執行該算法才是正確的,而算法正確執行之后總會帶來某種狀態的改變,稱為后置狀態(Postcondition),比如歸并,前置狀態要求兩個子序列是排序的,后置狀態要求整個序列是排序的,很多語言都提供了大同小異斷言機制,比如Java的assert指令,并提供了激活斷言的開關功能。那么我們就可以在代碼中使用斷言,這至少有兩個好處,首先斷言能夠盡可能早地發現程序中出現的潛在問題,提醒開發者代碼執行的條件沒有被滿足;其次,它是一種文檔,明確地告知了算法執行的先決條件和帶來的改變,能夠提高代碼的易讀性。
private static void merge(Comparable[] a, Comparable[] aux, int lo, int mid, int hi) {
// precondition: a[lo .. mid] and a[mid+1 .. hi] are sorted subarrays
assert isSorted(a, lo, mid);
assert isSorted(a, mid+1, hi);
// copy to aux[]
for (int k = lo; k <= hi; k++) {
aux[k] = a[k];
}
// merge back to a[]
int i = lo, j = mid+1;
for (int k = lo; k <= hi; k++) {
if (i > mid) a[k] = aux[j++]; // this copying is unnecessary
else if (j > hi) a[k] = aux[i++];
else if (less(aux[j], aux[i])) a[k] = aux[j++];
else a[k] = aux[i++];
}
// postcondition: a[lo .. hi] is sorted
assert isSorted(a, lo, hi);
}
3.3 各個擊破:自頂向下的歸并
有了歸并,排序就被設計為一個遞歸的過程:每次都計算一個中間位置mid,然后遞歸地排序左右兩部分,最后歸并排序好的子序列,當hi <= lo時遞歸返回,因為此時子序列中沒有元素,不需要做任何操作,代碼如下所示。因為sort是遞歸調用,因此每個sort調用都會包含自己的merge過程,也就保證了子序列在歸并前已經是有序的了。歸并排序是一個速度很快的算法,有科學家做過經驗分析,通過實驗分別在家用計算機和超級計算機上對比了她與插入排序的運行效率,得到如圖3-4所示的實驗結果,可以看到即使是百萬級的記錄歸并排序仍然是瞬間完成,插入排序卻需要等上300多年。
// mergesort a[lo..hi] using auxiliary array aux[lo..hi]
private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) {
if (hi <= lo) return;
int mid = lo + (hi - lo) / 2;
sort(a, aux, lo, mid);
sort(a, aux, mid + 1, hi);
merge(a, aux, lo, mid, hi);
}
對歸并排序的數學分析代表了對“分治”這一類算法進行分析的基本模式,掌握了這種分析方法,當我們面對各種分治算法時我們就可以用同樣的方法去分析它們的效率。分治算法的效率分析通常可以通過解某一遞推公式來完成,就歸并排序而言,因為我們每次都將序列一分為二排序后進行歸并,如果我們假設C(N)為算法對長度為N的序列進行排序所需比較次數,那么我們可以得到如圖3-5所示的遞推公式。解這個遞推公式可以得到歸并排序的效率為NlgN,具體的推導過程這里就不贅述了,《算法導論》這本書上有全面而詳細的指導。這里只給出一個比較直觀的解釋,如圖3-6所示。假設N為2的冪,我們可以將歸并排序一分為二的過程看成一棵樹,這棵樹每一層都有N個結點,它會一直分解,而樹的高度為lgN,所以最后得到NlgN。
3.4 積少成多:自底向上的歸并
自頂向下的方法通常都存在著一個對稱的逆過程,有時候我們也可以反過來想,用自底向上的思路去解決問題。前面提到,長度為1的序列是天然有序的。那么我們可以多遍歷幾次,第一次將長度為1的子序列歸并為有序的2-序列,然后將有序的2-序列歸并為4-序列……這樣反復進行下去,直到整個序列都歸并為有序的,這樣連遞歸都不用了,兩層循環就能搞定。說起來簡單,但要真正實現的話,一些細微的地方要注意。從下面的代碼示例可以看到,外層循環從1開始,每次加倍,因為第一次要處理大小為1的序列,第二次要處理大小為2的序列,第三次則是大小為4的,以此類推。然后在每一次循環的內部,用索引i來記錄每次要處理的序列頭部(一次迭代跳過sz+sz個元素)。我們分別計算lo,m和hi,使得aux[lo..m]和aux[m+1..hi]為兩個相等長度的待歸并子序列,然后仍然用上面提到的歸并方法進行處理。
public static void sort(Comparable[] a) {
int N = a.length;
Comparable[] aux = new Comparable[N];
for (int sz = 1; sz < N; sz = sz+sz) {
for (int i = 0; i < N-sz; i += sz+sz) {
int lo = i;
int m = i+sz-1;
int hi = Math.min(i+sz+sz-1, N-1);
merge(a, aux, lo, m, hi);
}
}
assert isSorted(a);
}
3.5 基于比較的排序算法,其極限何在?
我們已經分析過的選擇排序,插入排序,Shell排序和歸并排序,以及下面會談到的快速排序,都可以看作是一類排序算法——基于比較的排序算法,也就是說,它們只知道元素之間的大小關系,其他的信息(比如是否有重復元素,是否部分有序等等)則完全不知。對于這一類算法,我們可以通過一種叫“決策樹”的工具,得到一個計算復雜度方面的重要結論。圖3-7展示了對三個不同元素a,b和c的排序過程。從根節點到某一葉子結點的路徑表示某次排序的比較序列,葉子結點表示最后得到的結果。首先,對于N個不同元素的排序決策樹,至少有N!個葉子結點,因為有N!種排列的可能。其次,二叉樹有一個重要的性質,即高度為h的二叉樹最多有2h個葉子結點。故得到公式2h >= #leaf >= N!,即h >= lgN! >= NlgN(根據斯特林公式得到)。也就是說,我們對基于比較的排序算法建立的決策樹模型,其高度至少是NlgN,而樹的高度表示算法最大比較次數,那么我們就能得出一個結論:最壞情況下所有基于比較的排序算法至少要用NlgN次比較。
要注意,這是一個很重要的結論。從這個結論,我們可以得出這樣一個事實:歸并排序是時間最優的,因為它最壞情況下比較次數為NlgN。其次,不可能找到比較次數比這個還少的算法——因為這違反客觀規律了,但是,這個結論同時啟發我們,應該可以找到時間是NlgN,但空間效率更優的算法,可以從這個方向去想。另外一個細微之處在于,該結論依據的基本假設條件是該序列是由N個不同的元素組成的,若序列中出現重復元素,或該序列是部分有序的,那么算法的效率會比NlgN還要高。使用某個結論之前要考慮該結論成立的條件是否滿足,否則會鬧笑話的。