[本篇博文會對常見的排序算法進行分析與總結,並會在最後提供幾道相關的一線互聯網企業面試/筆試題來鞏固所學及幫助咱們查漏補缺。項目地址:https://github.com/absfree/Algo。因爲我的水平有限,敘述中不免存在不清晰準確的地方,但願你們能夠指正,謝謝你們:)] node
咱們在平常開發中常常須要對一組數據對象進行排序,這裏的數據對象不只包括數字,還多是字符串等抽象數據類型(Abstract Data Type)。因爲排序是不少其餘操做(好比二分查找)可以高效進行的基礎,所以咱們有必要掌握好常見的排序算法,本篇文章會分析幾種最經常使用的排序算法,並進一步探索排序的本質,從而可以更加全面透徹的理解各類排序算法。本篇博文會用Java來描述各類排序算法的實現,因爲本篇文章的側重點在與分析各項算法的原理及其通常實現,所以咱們假定待比較的數據對象均爲int類型(然而在實際應用中咱們應該假定它們爲Comparable類型)。若未加特殊說明,咱們如下的排序算法都會按照升序排列。在算法的具體實現中,咱們用到了StdRandom、StdOut和StdIn這三個靜態代碼庫,它們能夠在這裏https://github.com/absfree/Algo/tree/master/src/util找到,每一個方法的做用均可以經過它們的名稱看出來,源碼中也有相應的註釋。git
假如咱們如今按身高升序排隊,一種排隊的方法是:從第一名開始,讓兩人相互比身高,若前者高則交換位置,更高的那個在與剩下的人比,這樣一趟下來以後最高的人就站到了隊尾。接着重複以上過程,直到最矮的人站在了隊列首部。咱們把隊頭看做水底,隊尾看做水面,那麼第一趟比較下來,最高的人就像泡泡同樣從水底」冒「到水面,第二趟比較則是第二高的人……排隊的過程即爲對數據對象進行排序的過程(這裏咱們排序的」指標「是身高),上述過程即描述了冒泡排序的思想。從以上過程咱們能夠看到,若對n我的進行排隊,咱們須要n-1趟比較,並且第k趟比較須要進行n-k次比較。經過這些信息,咱們可以很容易的算出冒泡排序的複雜的。首先,排序算法一般都以數據對象的兩兩比較做爲」關鍵操做「,這裏咱們能夠得出,冒泡排序須要進行的比較次數爲: (n-1) + (n-2) + ... + 1 = n*(n-1) / 2,所以冒泡排序的時間複雜度爲O(n^2)。github
理解了冒泡排序的原理,就不難實現它了,具體實現代碼以下:面試
public class Bubble { public static void sort(int[] a) { int N = a.length; for (int i = 0; i < N - 1; i++) { for (int j = 0; j < N - i - 1; j++) { if (a[j] > a[j+1]) { exchange(a, j, j+1); } } } } public static void exchange(int a[], int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } public static void main(String[] args) { int N = 20; int[] a = new int[N]; for (int i = 0; i < N; i++) { a[i] = StdRandom.uniform(0, 1000); } sort(a); for (Integer i : a) { StdOut.print(i + " "); } } }
關於冒泡排序有一點須要注意的是,在最好狀況下(即輸入數組已經徹底有序),冒泡排序的時間複雜度可以提高到O(N)。咱們只需增長一個boolean型變量isOrdered,在第一輪排序中一旦a[j] > a[j+1],就把isOrdered設爲false,不然isOrdered設爲true,而後咱們在每趟排序前檢查isOrdered,一旦發現它爲false,即認爲排序已完成。算法
回到上面咱們提到的排隊問題,除了上面提到的方法,還有這樣一種排隊的方法,讓目前隊頭的人依次與其後的每一個人進行比較,比較後較矮的那我的繼續與後面的人進行比較,這樣第一趟比較下來,就可以找到最矮的人, 而後把這個最矮的人和當前隊頭的人交換一下位置。而後第二趟比較,讓第二名依次與後面比較,能夠找到第二矮的人,而後讓第二矮的人和當前隊列第二名交換位置,依此類推,一共進行n-1趟比較後,就能完成整個排隊過程。根據上述描述,咱們能夠知道,第k趟比較須要進行的數組元素的兩兩比較的次數爲n-k次,因此共須要的比較次數爲n*(n-1) / 2,所以選擇排序算法的時間複雜度與冒泡排序同樣,也爲O(n^2)。選擇排序的Java描述以下:數組
public class Selection { public static void sort(int[] a) { int N = a.length; for (int i = 0; i < N - 1; i++) { int min = i; for (int j = i + 1; j < N; j++) { if (a[j] < a[min]) { min = j; } } exchange(a, i, min); } } public static void exchange(int[] a, int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } }
回想下咱們平時打撲克抓牌的過程,一般咱們用右手抓牌,每抓一張牌,就放到左手上,抓下一張牌後,會把這張牌依次與左手上的牌比較,並把它插入到一個合適的位置(一般按照牌面大小)。上述的過程即爲插入排序的過程,假設待排序數組爲a,咱們從a[1]開始,讓a[1]與a[0]比較,若a[1]較小,則讓a[1]和a[0]交換位置,此時a[0]和a[1]就至關於已經放入左手中的牌。而後咱們再讓a[2]與a[1]、a[0]比較,併爲它找到一個合適的位置,以此類推,直到爲數組的最後一個元素也找到了合適的位置。緩存
理解了插入排序的思想後,咱們便可以獲得它的時間複雜度。對於n個元素,一共須要進行n-1輪比較,而第k輪比較須要進行k次數組元素的兩兩比較,所以共須要進行的比較次數爲:1 + 2 + ... + (n-1),因此插入排序的時間複雜度同冒泡排序同樣,也爲O(n^2)。插入排序的Java描述以下:數據結構
public class Insertion { public static void sort(int[] a) { int N = a.length; int i, j; for (i = 1; i < N; i++) { for (j = i - 1; j >= 0 && a[i] < a[j]; j--) { } //這裏跳出內層循環,a[i]應被插入到a[j]後 int tmp = a[i]; for (int k = i; k > j + 1; k--) { a[k] = a[k-1]; } a[j+1] = tmp; } } }
咱們來簡單地解釋下以上代碼。以抓牌過程來舉例,i爲剛抓的牌的索引,i-1即爲咱們剛排好的牌中的最後一張的索引,j爲左手中當前正與咱們剛抓的牌進行比較的牌的索引。在內層循環中,咱們從左手已排好牌中的最後一張開始,若發現剛抓的牌比當前牌的牌面大,就再與前一張比較(j--),直到剛抓的牌大於等於當前牌的牌面,就會跳出內層循環,這時咱們把a[i]插入到a[j]後,就把剛抓的牌插入到已排好牌中的合適的位置了。重複以上過程就能完成待排序數組的排序。dom
關於插入排序咱們須要注意的是,在平均狀況下以及最壞狀況下,它的時間複雜度均爲O(n^2),而在最好狀況下(輸入數組徹底有序),插入排序的時間複雜度可以提高至O(N)。實際上,排序的本質就是消除逆序對,所謂逆序對,就是不符合咱們所要求的排序順序的兩個數。好比說[1,3,4,2]爲待排序數組,那麼它的逆序數爲2——(3,2)和(4,2)都是降序的,不符合咱們升序的要求。插入排序對於部分有序的數組的排序尤其有效,所謂部分有序,指的是待排序數組的逆序數小於數組尺寸的某個倍數。若咱們待排序數組徹底有序時,每一輪排序都只需比較一次,就能找到待排序元素在已排序數組中的合適的位置,而部分有序時,比較的次數也能控制在數組尺寸的常數倍以內。所以,插入排序對於部分有序的數組十分高效,也很適合小規模的數組。函數
希爾排序是對插入排序的一種改進,它的核心思想是將待排序數組中任意間隔爲h的元素都變爲有序的,這樣的數組叫作h有序數組。好比數組[5, 3, 2, 8, 6, 4, 7, 9, 5], 咱們能夠看到a[0]、a[3]、a[6]是有序的,a[1]、a[4]、a[7]是有序的,a[2]、a[5]、a[8]是有序的,所以這個數組是一個h有序數組(h=3)。根據h有序數組的定:義,咱們能夠知道,當h=1時,相應的h有序數組就是一個已經排序完畢的數組了。希爾排序的大體過程以下:把待排序數組分割爲若干子序列(一個子序列中的元素在原數組中間隔爲h,即中間隔了h-1個元素),而後對每一個子序列分別進行插入排序。而後再逐漸減少h,重複以上過程,直至h變爲足夠小時,再對總體進行一次插入排序。因爲h足夠小時,待排序數組的逆序數已經很小,因此再進行一次希爾排序是很快的。希爾排序一般要比插入排序更加高效。
實現希爾排序時,咱們須要選取一個h的取值序列,這裏咱們直接採用算法(第4版) (豆瓣)一書中提供的h取值序列(1,4,13,40,121, ...)。即h = 3 * k + 1,其中k爲[0, N/3)區間內的整數。希爾排序的Java描述以下:
public class Shell { public static void sort(int[] a) { int N = a.length; int h = 1; while (h < N / 3) { h = 3 * h + 1; //h的取值序列爲1, 4, 13, 40, ... } while (h >= 1) { int n, i ,j, k; //分割後,產生n個子序列 for (n = 0; n < h; n++) { //分別對每一個子序列進行插入排序 for (i = n + h; i < N; i += h) { for (j = i - h; j >= 0 && a[i] < a[j]; j -= h) { } int tmp = a[i]; for (k = i; k > j + h; k -= h) { a[k] = a[k-h]; } a[j+h] = tmp; } } h = h / 3; } } }
實際上,h的取值序列的選取會影響到希爾排序的性能,不過以上咱們選取的h值序列在一般狀況下性能與複雜的取值序列相接近,可是在最壞狀況下的性能要差一些。分析希爾排序的複雜度不是一件容易的事,這裏咱們引用《算法》一書中關於希爾排序複雜度的結論:
使用遞增序列1, 4, 13, 40, 121, 364, ...的希爾排序所需的比較次數不會超過數組尺寸的若干倍乘以遞增序列的長度。
也就是說,在一般狀況下,希爾排序的複雜度要比O(n^2)好得多。實際上,最壞狀況下希爾排序所須要的比較次數與O(n^1.5)成正比,在實際使用中,希爾排序要比插入排序和選擇排序、冒泡排序快得多。並且儘管待排序數組很大,希爾排序也不會比快速排序等高級算法慢不少。所以當須要解決排序問題而用沒有現成系統排序函數可用時,能夠優先考慮希爾排序,當希爾排序確實知足不了對性能的要求時,在考慮使用快速排序等算法。
到這裏,咱們要介紹的基本排序算法就介紹完了,再介紹快速排序、歸併排序、堆排序等高級排序算法前,咱們先來簡單地介紹下如何比較各類排序算法的實際性能,這也可以幫助咱們直觀的看到希爾排序相比與插入排序等的性能優點。
儘管插入排序和選擇排序的複雜度都爲O(n^2),可是它們所包含的常數係數是不一樣的,於是這兩種算法的實際執行時間之比應該是一個常數,下面咱們來設計實驗來測試下以上咱們介紹的幾種基本排序算法的實際執行性能。相關代碼以下:
public class SortCompare { public static double time(String alg, int[] a) { long startTime = System.currentTimeMillis(); if (alg.equals("Insertion")) { Insertion.sort(a); } else if (alg.equals("Selection")) { Selection.sort(a); } else if (alg.equals("Bubble")) { Bubble.sort(a); } else if (alg.equals("Shell")) { Shell.sort(a); } long endTime = System.currentTimeMillis(); return (double) (endTime - startTime) / 1000.0; } public static double timeRandomInput(String alg, int N, int T) { //使用alg指定的排序算法將長度爲N的數組排序,共排序T次,並計算總時間 double total = 0.0; int[] a = new int[N]; for (int t = 0; t < T; t++) { for (int i = 0; i < N; i++) { a[i] = StdRandom.uniform(10 * N); } total += time(alg, a); } return total; } public static void main(String[] args) { String alg1 = args[0]; String alg2 = args[1]; int N = Integer.parseInt(args[2]); int T = Integer.parseInt(args[3]); double t1 = timeRandomInput(alg1, N, T); double t2 = timeRandomInput(alg2, N, T); StdOut.printf("For %d random ints\n %s is", N, alg1); StdOut.printf(" %.1f times faster than %s", t2/t1, alg2); } }
咱們來對1000個數進行排序,來比較下以上介紹的算法的性能。我這裏獲得的輸出結果以下:
For 1000 random ints Shell is 4.9 times faster than Insertion For 1000 random ints Shell is 7.6 times faster than Selection For 1000 random ints Shell is 11.7 times faster than Bubble
咱們能夠直觀的看到,希爾排序要比其餘三種排序都快,而插入排序要比選擇排序、冒泡排序快,冒泡排序在實際執行性能最差。
基本排序算法對於中小規模的數據集的排序在通常狀況下足夠用了,可是對於大規模數據集的排序,咱們仍是頗有必要使用一些較高級的排序算法,下面咱們來逐一介紹它們。
歸併排序使用了一種叫作」分治「的思想來解決排序問題。分治也就是"分而治之「,也就是把一個大問題分解爲衆多子問題,然後分別獲得每一個子問題的解,最終以某種方式合併這些子問題的解就能夠獲得原問題的解。歸併排序的主要思想是:將待排序數組遞歸的分解成兩半,分別對它們進行排序,而後將結果「歸併」(遞歸的合併)起來。咱們知道,遞歸算法都有一個base case,遞歸分解數組的base case就是分解完的兩個數組長度爲爲1,這時候它們自己就有序,此時就能夠進行歸併了。
歸併排序的時間複雜度爲O(nlogn), 它的主要缺點是所需的額外空間與待排序數組的尺寸成正比。
首先,咱們先來實現歸併方法,這個方法接收一個int[]數組a以及low、mid、high參數,用於將a[low..mid](表明a[low]到a[mid]間的元素,包括a[low]和a[mid])和a[mid+1..high]歸併爲一個數組,這個方法假設a[low..mid]與a[mid+1..high]都是有序的。
下面咱們用一個具體例子來描述歸併算法的執行過程,假如咱們的輸入數組爲[2, 4, 6, 8, 1, 3, 5, 7],low爲0,mid爲3,high爲7。咱們稱a[low..mid]爲左數組,a[mid+1..high]爲右數組,歸併方法的執行過程以下:
理解了歸併方法的原理,咱們就不難用Java來描述它了,相關代碼以下:
private static void merge(int[] a, int low, int mid, int high) { int i = low; //左數組下一個要進行比較的元素的索引 int j = mid + 1; //右數組下一個要進行比較的元素的索引 int N = high + 1; //本次歸併的元素數目 int[] tmpArray = new int[N]; //用於暫時存放比較後的元素 for (int k = low; k <= high; k++) { if (i > mid) { //左數組元素已全比較完 tmpArray[k] = a[j++]; } else if (j > high) { //右數組元素已全比較完 tmpArray[k] = a[i++]; } else if (a[j] < a[i]) { //右數組元素小於左數組 tmpArray[k] = a[j++]; } else { //右數組元素大於等於左數組 tmpArray[k] = a[i++]; } } for (int k = low; k < N; k++) { a[k] = tmpArray[k]; } }
在以上代碼中,咱們使用了一個輔助數組tmpArray來暫時存放比較後的數組元素,待歸併完成後,再複製回原數組。
上面咱們介紹了歸併過程的實現,歸併方法要求輸入數組的左半部分和右半部分分別有序。那麼下面咱們來介紹如何利用上面咱們實現的merge方法來實現對一個數據集的歸併排序。
在最開始咱們介紹過歸併排序的主要思想是將待排序數組遞歸的分解成兩半,分別對它們進行排序,而後將結果歸併起來。具體過程以下:將數組遞歸的分爲兩部分,直至兩部分長度都爲1,則認爲到達了base case,這時開始執行歸併過程。
這裏咱們仍是以上面的輸入數組舉例,遞歸分解數組的示意圖以下:
咱們能夠看到,當數組分解爲只有單個元素後,那麼它就是有序的了,因此這時就知足了上面咱們實現的歸併方法的輸入參數的條件,咱們經過調用歸併方法就可以以單元素數組爲起點,逐步構造出已排序的完整數組。相關的實現代碼以下:
public class Merge { private static void merge(int[] a, int low, int mid, int high) { ... } public static void sort(int[] a) { int N = a.length; sort(a, 0, N - 1); } private static void sort(int[] a, int low, int high) { //base case if (high <= low) { return; } int mid = (low + high) / 2; sort(a, low, mid); sort(a, mid+1, high); merge(a, low, mid, high); } public static void main(String[] args) { int N = 20; int a[] = new int[N]; for (int i = 0; i < N; i++) { a[i] = StdRandom.uniform(1000); } sort(a); for (Integer i : a) { StdOut.print(i + " "); } } }
若是感受以上代碼比較抽象,你們能夠畫出「遞歸調用圖」來幫助咱們理解遞歸調用的過程,仍是以上面的輸入數組爲例,咱們畫一下對它進行歸併排序的遞歸調用圖:
經過這個圖,咱們能夠直觀地看到sort方法的遞歸調用過程。對於以上sort方法,如下幾個方法可以提高它的性能:
快速排序是目前應用最普遍的排序算法之一,它是通常場景中大規模數據排序的首選,它的實際性能要好於歸併排序。一般狀況下,快速排序的時間複雜度爲O(nlogn),但在最壞狀況下它的時間複雜度會退化至O(n^2),不過咱們能夠經過對輸入數組進行「隨機化」(打亂元素的排列順序)來避免最壞狀況的發生。除了實際執行性能好,快速排序的另外一個優點是它可以實現「原地排序」,也就是說它幾乎不須要額外的空間來輔助排序。下面咱們來具體介紹下這個優秀排序算法的原理及實現。
快速排序的主要思想以下:假設待排序數組爲a[0..N-1],遞歸的對該數組執行如下過程:選取一個切分元素,然後經過數組元素的交換將這個切分元素移動到位置j,使得全部a[0..j-1]的元素都小於等於a[j],全部a[j+1..N-1]的元素都大於等於a[j]。
在快速排序中,切分元素的選取很關鍵,一般咱們能夠選取輸入數組的第一個元素做爲切分元素,而後把它交換到數組中的合適位置使得它左邊的元素都小於等於它,右邊的元素都大於等於它,然後對其左右兩邊的子數組遞歸執行切分過程,便可完成對整個數組的排序。下面咱們來看一下切分方法的Java描述,並以此來說解切分過程的具體實現:
1 private static int partition(int[] a, int low, int high) { 2 int i = low + 1; 3 int j = high + 1; 4 5 //p爲切分元素 6 int p = a[low]; 7 while (true) { 8 //從數組中的第二個元素開始尋找第一個大於等於切分元素的數組元素,若找到則i爲其索引 9 while (a[++i] < p) { 10 if (i == high) { 11 break; 12 } 13 } 14 //此時i爲從數組首部開始第一個大於等於切分元素的數組元素的索引,若沒有找到則爲high 15 16 //從數組末元素開始尋找第一個小於等於切分元素的數組元素,若找到則j爲其索引 17 while (a[--j] > p) { 18 if (j == low) { 19 break; 20 } 21 } 22 //此時j爲從數組末開始第一個小於等於切分元素的數組元素的索引,若沒有找到則爲low 23 24 if (i >= j) { 25 break; 26 } 27 exchange(a, i, j); 28 } 29 exchange(a, low, j); 30 return j; 31 }
結合以上代碼,咱們來說解一下肯定切分過程的具體實現。首先在第6行中,咱們選取了數組的首元素做爲切分元素並將它保存在變量p中,而後在第7行進入一個無限循環中。
第9行到第13行是一個內層循環,在這個循環中,咱們從數組的第二個元素開始,讓切分元素p與每一個數組元素進行比較,當相應位置的元素大於等於p或是已經到達數組末尾時,這個循環就會終止。此時i的值爲第一個大於等於p的元素的索引或是high的值,若爲high的值則表示數組中不存在大於等於p的元素。
而後咱們來到了第17行到第21行的內層循環中,這個循環會從數組末元素開始,讓p與數組元素逐一進行比較,當相應位置的元素小於等於p或是已比較到數組首時則終止循環。此時j的值爲第一個小於等於p的元素的索引或是low的值,若爲low的值則表示數組中不存在小於等於p的元素。
接下來,執行第24行的if語句判斷i和j的大小,若i >= j, 會跳出無限循環。i >= j對應着如下四種狀況:
下面咱們再來看一下當i < j時咱們應該怎麼作。首先咱們須要明確的是i < j意味着第一個大於等於p的元素(a[i])在第一個小於等於p的元素(a[j])的左邊。以下圖所示:
那麼如今問題來了,a[i]和a[j]之間的元素和p的關係是怎樣的呢?答案是沒法肯定,因此當i<j時咱們還不能貿然退出無限循環,得先把a[i]與a[j]之間的元素與p的大小關係肯定了才行。不過如今的問題是出現了兩隻「攔路虎」——忽然出現了a[i]這個大於等於p的元素攔着咱們讓咱們沒法繼續向數組尾部尋找小於p的元素,而a[j]這個小於等於p的元素的出現使得咱們沒法向數組頭部探索是否還有大於p的元素。那麼解決方法來了,咱們只要想辦法移除這兩個擋道的不就行啦。慢着...交換下a[i]和a[j]不就行了,這樣咱們就能夠繼續探索了呀,再遇到攔路虎的時候再交換它倆就能夠了呀...以上代碼第27行就完成了這個交換的工做。
關於切分方法還有一點須要咱們注意的是:在從左向右「掃描」時,必須在遇到大於等於切分元素p的元素時停下來,在從右向左掃描時,必須在遇到小於等於切分元素p的元素時停下來。若不是這樣作的話,當數組有大量重複元素時,快速排序的時間複雜度就會退化至O(n^2)。
如今,咱們已經結合源代碼,比較詳細地闡述了切分過程的實現。下面,讓咱們藉助這個切分過程,來實現用快速排序算法對一個數組進行排序。
實際上,搞懂了上面的切分過程,來具體實現快速排序是很容易的,參考代碼以下:
1 public static void sort(int[] a) { 2 StdRandom.shuffle(a); //打亂輸入數組的元素間的相對順序,避免出現最壞狀況 3 sort(a, 0, a.length - 1); 4 } 5 6 private static void sort(int[] a, int low, int high) { 7 if (high <= low) { 8 return; 9 } 10 int j = partition(a, low, high); 11 sort(a, low, j-1); 12 sort(a, j+1, high); 13 }
跟咱們前面所描述的快速排序的基本思想同樣,遞歸地對待排數組進行切分就可以完成排序。這裏咱們簡單介紹下快速排序的性能特色。快速排序算法的實際執行性能依賴與切分是否均衡,當正好把數組從中間」切開」時,快速排序的實際性能最好。切分越不均衡快速排序的實際性能就越差,最壞狀況下(第一次選取的切分元素是數組裏最小的,第二次的切分元素是第二小的...),算法的時間複雜度會退化到O(n^2)。因此爲了不最壞狀況的發生,咱們在使用快速排序對數組排序時,會先打亂一下數組元素的順序。一個好消息是在平均狀況下,咱們將數組打亂後再取第一個元素做爲切分元素,切分一般是比較均衡的。
儘管快速排序已經具備很是優秀的實際性能,可是仍然有許多行之有效的方法可以明顯提高快速排序的速度,下面咱們將簡單地介紹如下這些方法。
對於尺寸比較小的數組,插入排序要比快速排序快,所以當遞歸調用切分方法到切分所得數組已經很小時,咱們不妨用插入排序來排序小數組。只須要把以上快速排序實現代碼的7—9行改成以下:
if (high <= low + SIZE) { //SIZE爲使用插入排序的臨界數組尺寸,能夠選取[5,15]上的整數 Insertion.sort(a, low, high); return; }
這項改進方案的手段是改進切分過程,具體方法以下:在每次切分時,從待切分數組中隨即抽取3個元素,然後計算出它們3個元素的中位數來做爲切分元素。也就是說,相比於上面咱們實現的快速排序,三取樣切分就是在切分元素的選取上有所不一樣。如下是三取樣切分的實現:
public class Quick3d { public static void sort(int[] a) { sort(a, 0, a.length); } private static void sort(int[] a, int low, int high) { if (high <= low) { return; } int lt = low; int i = low + 1; int gt = high; int p = a[low]; while (i <= gt) { if (a[i] < p) { exchange(a, i++, lt++); } else if (a[i] > p) { exchange(a, i++, gt--); } else { i++; } } } public static void exchange(int a[], int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } }
介紹堆排序以前,咱們須要先介紹一種經常使用的數據結構——優先隊列,由於堆排序就是基於優先隊列實現的。優先隊列能夠分爲最大優先隊列和最小優先隊列,最大優先隊列主要支持兩種操做:插入元素和刪除最大元素,最小優先隊列則支持插入元素和刪除最小元素。在下面的介紹中,若未加特殊說明,咱們所說的優先隊列指的是最大優先隊列。優先隊列適用於以下這種場景:不須要對數據集徹底有序,咱們只須要獲取數據集最大的一個或幾個元素。優先隊列能夠基於數組實現,也能夠基於二叉堆來實現,一般二叉堆都基於二叉堆來實現,因此這裏咱們主要介紹這種實現。
在介紹基於二叉堆實現的優先隊列前,咱們先來介紹幾個概念,這幾個概念的定義均來自於Sedgewick的《算法》一書。第一個咱們要介紹的概念堆有序,它的定義以下:
當一棵二叉樹的每一個結點都大於等於它的兩個子結點時,它被稱爲堆有序。
第二個概念是二叉堆,它的定義以下:
二叉堆是一組可以用堆有序的徹底二叉樹排序的元素,並在數組中按照層級存儲(不用數組的第一個位置)。
咱們來畫張圖說明一下堆有序的徹底二叉樹是怎樣按照層級存儲在數組中的:
從上圖中咱們能夠看到二叉樹中的元素是怎樣和數組中對應的。使用這種順序將二叉樹元素存儲在數組中所帶來的一個最直接的好處就是很容易定位到一個數組元素a[k]在樹中的父結點和兩個子結點在數組的的位置:a[k]的父結點爲a[k/2],左子結點爲a[2*k], 右子結點爲a[2*k+1]。這種容易定位父子結點的性質加上下面的一個二叉堆的特性使得咱們可以基於二叉堆高效的實現優先隊列:一棵大小爲N的徹底二叉樹的高度爲floor(lgN)。(其中floor表示向下取整,lgN表示以2爲底的N的對數)
咱們經過前面對二叉堆的定義能夠知道,二叉堆可看作一棵堆有序的徹底二叉樹,因此二叉堆的各個元素間是有着必定的相對順序的。具體說來,就是每一個結點都大於等於它的兩個子結點。所謂堆的有序化是指:當咱們向二叉堆中添加一個元素或從二叉堆中刪除一個元素後,致使二叉堆的有序性貝爾打破,這時咱們要經過某種過程來恢復二叉堆的有序性,這個過程就是堆的有序化。(若未作特殊說明,如下提到的「堆」均指「二叉堆」)。
堆的有序化可分爲兩種,一種是由下向上的有序化,一般叫作上浮(swim);還有一種是由上向下的有序化,一般叫作下沉(sink)。它們的名字便很清楚了代表了它們各自的做用,上浮就是讓一個結點向上移動到知足堆有序的位置,下沉就是讓一個結點向下移動到知足堆有序的位置,下面咱們來分別介紹它們。
什麼狀況下咱們須要上浮呢?一般是某個結點的值變大或是咱們向堆中添加一個新結點時(它會被添加到數組尾部,也就是成爲堆的葉子結點),咱們須要把這個結點上浮到一個合適的位置以保證堆有序。根據堆有序的定義,當咱們要進行上浮的結點大於它的父結點時,咱們就須要把它不斷的上浮,直到它小於等於它的父結點。參考代碼以下:
private void swim(int k) { while (k > 1 && a[k/2] < a[k]) { exchange(k, k/2 ); k = k / 2; } }
當某個結點的值比它的某個子結點更小時,咱們須要把該結點下沉來保證堆的有序性。下沉操做的過程當中,咱們應當先比較被下沉結點的兩個子結點的大小,然後讓被下沉結點與較大的那個比較,如果小於它,則二者交換,然後重複這個過程直到父結點大於等於兩個子結點或是到達末尾。參考代碼以下:
private void sink(int k) { while (2 * k <= N) { int j = 2 * k; if (j < N && a[2*k] < a[2*k+1]) { j++; } if (k >= j) { break; } exchange(k, j); k = j; } }
瞭解了堆的有序化的過程,優先隊列的insert方法以及delMax方法的實現就很容易了,下面咱們來基於以上的swim和sink方法來介紹insert與delMax方法的具體實現。爲簡單起見,咱們假設結點均爲int型。
有了上面的鋪墊,實現insert方法就很簡單了,咱們只須要把新結點添加到數組a的尾部,而後把它上浮到合適的位置便可,具體實現代碼以下:
public void insert(int node) { a[++N] = node; swim(N); }
因爲咱們始終保持二叉堆處於有序狀態,因此根結點就是最大的結點,咱們能夠刪除根結點,而後把數組尾部結點放入根結點的位置,再把它進行下沉便可,參考代碼以下:
public int delMax(int k) { exchange(1, N); int max = a[N--]; a[N+1] = -1; sink(1); return max; }
如今咱們已經成功實現了一種insert方法與delMax方法的複雜度均爲O(logn)的優先隊列。實際上咱們上面實現的每次能夠刪除一個最大結點的優先隊列叫作最大有限隊列,與它相對的每次能夠刪除一個最小結點的優先隊列就是最小優先隊列。接下來讓咱們基於(最大)優先隊列來實現堆排序。
咱們知道,每次調用優先隊列的delMax方法都會刪除一個最大的結點,其時間複雜度爲O(logN)。那麼對於一個大小爲N的數據集,咱們只須要將它包含的元素都添加到優先隊列中,而後調用N次delMax不就能夠實現排序了嗎?實際上這種區別與以前咱們所介紹的排序方法的排序實現就是堆排序,堆排序的時間複雜度爲O(nlogn)。
堆排序一般分爲兩個階段,第一個階段是堆的構造階段,用於把咱們輸入的無序數組構形成二叉堆;第二個階段是下沉排序階段,這個階段咱們刪除一個最大結點並下沉以保證堆有序。下面咱們來具體介紹這兩個階段的實現。
這個階段咱們的任務是把一個無序數組構形成一個二叉堆,要實現這一任務,咱們能夠從數組的尾部開始對每一個元素調用sink方法,若一個結點的兩個子結點都已是堆,那麼咱們對該結點調用sink就能夠將它們整合成一個堆。對於沒有子結點的堆,咱們無需對其調用sink方法。
下沉排序的邏輯很簡單,就是讓最大結點(即根結點)與數組末尾對應的結點交換,這樣就把最大結點移動到了數組末尾,而後把剛交換到根結點的結點進行sink,此時根結點即爲第二大的結點,而後再將根結點與數組末尾對應的結點交換….這樣重複N-1次就能實現數組的原地排序。
結合以上兩個階段,就能夠獲得堆排序的完整實現:
public class Heap { private static void sink(int[] a, int k, int N) { while (2 * k <= N) { int j = 2 * k; if (j < N && a[j] < a[j+1]) { j++; } if (a[k] >= a[j]) { break; } exchange(a, k, j); k = j; } } public static void sort(int[] a) { int N = a.length - 1; for (int k = N/2; k >= 1; k--) { sink(a, k, N); } while (N > 1) { exchange(a, 1, N--); sink(a, 1, N); } } public static void exchange(int a[], int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } }
《算法》一書中對堆排序評價以下:
堆排序是咱們所知的惟一可以同時最優地利用空間和時間的方法——在最壞狀況下他也能保證~2NlgN次比較和恆定的額外空間。
其中~2NlgN表示比較次數的增加量級爲2NlgN。也就是說若設比較次數爲f(N),當N足夠大時,f(N) / 2NlgN趨向於1。因爲這個特性,堆排序算法適合於空間資源十分緊張的嵌入式系統。
堆排序的一個主要缺點在於緩存不友好,由於它常常要對內存中不相鄰的元素進行比較,因此緩存命中率要低於快速排序、歸併排序等算法。
在比較各個排序算法前,咱們先來介紹如下穩定性這個重要的概念。它的定義以下:
若是一個排序算法可以保留數組中重複元素的相對位置則能夠被稱爲是穩定的。
這個性質在有些場景中是必要的,特別是咱們要對數據集進行多輪排序時。好比咱們要排序的是交易事務數據集,每一個交易事務都有交易時間和交易金額等信息。咱們第一輪先按照交易金額排序,而後咱們想再對於這些交易事務按照交易時間排一次序。此時若排序算法是穩定的,上一步具備相同交易時間的事務在第二輪排序後的相對順序是不變的,而若算法不穩定第二輪對交易時間的排序會破壞第一輪排序的成果。顯然咱們在這種狀況下更但願排序算法是穩定的。
咱們前面介紹的幾種算法中,穩定的排序算法有冒泡排序、插入排序和歸併排序,而選擇排序、希爾排序、快速排序和堆排序都是不穩定的。
所謂的原地排序指的是對待排數組進行排序時只需在原數組處來回移動數組元素來實現排序。咱們以前介紹的排序算法中,原地排序的算法有:選擇排序、插入排序、希爾排序、快速排序與堆排序;非原地排序算法只有歸併排序(咱們使用了tmpArray來輔助排序)。
這一部分咱們來一塊兒解決幾道一線互聯網企業的關於排序的面試/筆試題,以檢驗咱們的學習成果以及可以讓咱們在之後的面試中增添一份信心。
【2015阿里巴巴研發工程師筆試題】個數約爲50K的數列須要進行從小到大排序,數列特徵是基本逆序(多數數字從大大小,個別亂序),如下哪一種排序算法在事先不瞭解數列特徵的狀況下性能最優。( ) A. 冒泡排序 B. 改進冒泡排序 C. 選擇排序 D. 快速排序 E. 堆排序 F.插入排序
根據題目中的描述,首先咱們能夠排除A、B、C,由於它們的時間複雜度都是O(n)。接下來咱們看下D選項,咱們前面提到過,快速排序在最壞狀況下的時間複雜度會退化至O(n^2),F選項的插入排序在逆序數很大時性能也不好(O(n^2))。而堆排序在最壞狀況下的複雜度也爲O(logn),因此這裏咱們應該選擇堆排序。
【2016阿里巴巴校招筆試題】現有1GB數據進行排序,計算資源只有1GB內存可用,下列排序方法中最可能出現性能問題的是( )
A. 堆排序 B. 插入排序 C. 歸併排序 D. 快速排序 E. 選擇排序 F. 冒泡排序
根據題目的描述,咱們可以很明確的知道這道題考察咱們的是原地排序的概念,這裏咱們只須要選擇非原地排序的佔用額外空間最大的算法,顯然答案是」C. 歸併排序"。
【京東】假設你只有100Mb的內存,須要對1Gb的數據進行排序,最合適的算法是( )
A. 歸併排序 B. 插入排序 C. 快速排序 D. 冒泡排序
根據題目,咱們能夠知道,咱們現有的內存限制使得咱們沒法把數據一次性加載到內存中,因此咱們只能先加載一部分數據,對其排序後存入磁盤中。而後再加載一些數據,把它們「合併」到已排序的數據集中去,重複這個過程直到排序完成,顯然最能勝任這個工做的是歸併排序。
【選自《劍指offer》】輸入n個整數,找出其中最小的K個數。例如輸入4,5,1,6,2,7,3,8這8個數字,則最小的4個數字是1,2,3,4,。
初看這道題,根據前面的介紹,咱們馬上就可以想好幾種方案:
第一個方案是使用(最小)優先隊列。具體方法就是把輸入數組input中的元素都添加到優先隊列中,而後調用k次delMin方法咱們就能歐獲得最小的k個數字。相信這種解法的代碼在理解了優先隊列的實現後咱們你們都能寫出來。
第二個方案是使用冒泡排序k輪。
第三個方案是使用快速排序中的partition方法。咱們知道partition方法會返回一個索引j,會把原數組切分爲a[low..j-1](所包含元素均小於等於a[j])和a[j..high](所包含的元素都大於等於a[j],N爲輸入數組的尺寸)。這裏咱們初始化low爲0,high爲input..length-1,而後調用partition方法。若返回的j等於k-1(意味着a[low..j]中的元素數等於k),則返回a[low..j]便可;若j大於k-1(意味着a[low..j]包含的元素數大於k),此時咱們把partition的high參數更新爲j-1;若j小於k-1(意味着a[low..j]的元素數小於k,此時咱們把low更新爲j+1)。以上狀況中,只要j不等於k-1,咱們就根據j的與k-1的關係更新low或是high而後繼續調用partition方法,直到返回的j等於k-1。具體實現代碼以下:
public ArrayList<Integer> GetLeastNumbers_Solution(int[] input, int k) { if (input == null) { return null; } ArrayList<Integer> list = new ArrayList<Integer>(k); int low = 0; int high = input.length - 1; int j = partition(input, low, high); while (j != k-1){ if (j > k-1){ high = j - 1; } else { low = j + 1; } j = partition(input, low, high); } for (int i = 0; i < k; i++) { list.add(input[i]); } return list; } private static int partition(int[] a, int low, int high) { int i = low; int j = high + 1; int p = a[low]; while (true) { while (a[++i] < p) { if (i == high) { break; } } while (a[--j] > p) { if (j == low) { break; } } if (i >= j) { break; } exchange(a, i, j); } exchange(a, low, j); return j; }
《算法(第四版)》(Sedgewick等)
http://blog.csdn.net/shakespeare001/article/details/51280814