幾種常見的排序算法
冒泡排序(Bubble Sort):
冒泡排序是一種計算機科學領域的較簡單的排序算法。以數字排序為例,冒泡排序讓相連的兩個數字進行比較,將比較大的數字放在右邊。假設最大的數字N在最左邊。第一趟排序的時候,N每次和右邊的數字做對比,都將比右邊的數字大,然后將N一直往右移動只到最右。情形猶如冒泡,因此稱為冒泡排序。
以下為冒泡排序的一次范例:
原始數據 | 10 | 1 | 66 | 20 | 19 | 3 |
第1次排序 | 1 | 10 | 20 | 19 | 3 | 66 |
第2次排序 | 1 | 10 | 19 | 3 | 20 | 66 |
第3次排序 | 1 | 10 | 3 | 19 | 20 | 66 |
第4次排序 | 1 | 3 | 10 | 19 | 20 | 66 |
第5次排序 | 1 | 3 | 10 | 19 | 20 | 66 |
第一趟排序對數組的0、1位置進行排序,10>1,將1放在10之后;10<66,不動;66>20,對換20跟66的位置;66>19;對換66跟19的位置;66>3,對換66跟3的位置。從而獲取到了最大值66。
第二趟排序進行跟上面一樣的對比,只是66可以不參與比較。獲取第二大的數字20
第三趟排序獲取第三大的數字19。
。。。。。。
重復步驟,完成數字排序。
以java代碼為例,進行編碼:
public static void bubbleSorted(Integer[] list) { Integer temp; for (int i = 0; i < list.length; i++) { //如果沒有進行數據調換,則說明排序完成 boolean didSwap = false; for (int j = 0; j < list.length - i - 1; j++) { if (list[j] > list[j + 1]) { temp = list[j]; list[j] = list[j + 1]; list[j + 1] = temp; didSwap = true; } } if (didSwap == false) { return; } } }
分析:冒泡排序每一次都進行了N次迭代,因此排序的時間復雜度為O(n²)。它過於簡單了,以至於可以毫不費力地寫出來。然而當數據量很小的時候它會有些應用的價值,數據量比較小的時候其實也有其他更優的選擇,所以一般不會使用冒泡排序。
插入排序(insertion Sort):
插入排序是一種最簡單的排序算法,假設待排序的數組長度為N,插入排序必須進行N-1趟排序。插入排序的第M趟排序,保證數組的0位置開始到M位置處於已排序狀態。
以下為插入排序的一次范例:
原始數據 | 10 | 1 | 66 | 20 | 19 | 3 |
第1次排序 | 1 | 10 | 66 | 20 | 19 | 3 |
第2次排序 | 1 | 10 | 66 | 20 | 19 | 3 |
第3次排序 | 1 | 10 | 20 | 66 | 19 | 3 |
第4次排序 | 1 | 10 | 19 | 20 | 66 | 3 |
第5次排序 | 1 | 3 | 10 | 19 | 20 | 66 |
第一趟排序對數組的0、1位置進行排序,10>1,將1放在10之后。
第二趟排序對數組的0、1、2位置進行排序,1<10<66,不進行數據移動。
第三趟排序對數組的0、1、2、3位置進行排序,1<10<19<20。移動數據。
。。。。。。
重復上述步驟,直到數組中的所有元素排序完成。
以java代碼為例,進行編碼:
1 public static void insertionSorted(Integer[] list) { 2 for (int i = 1; i < list.length; i++) { 3 Integer temp = list[i]; 4 int j; 5 for (j = i; j > 0 && list[j - 1] > temp; j--) { 6 list[j] = list[j - 1]; 7 } 8 list[j] = temp; 9 } 10 }
分析:由於插入排序每一次都進行了N次迭代,因此插入排序的時間復雜度為O(n²)。而且在數組的數量比較小的情況下,插入排序的速度是很可觀的。插入排序跟冒泡排序經常會拿起來做比較,看下方數據比較:
算法名稱 | 最差時間復雜度 | 平均時間復雜度 | 最優時間復雜度 | 空間復雜度 | 穩定性 |
冒泡排序 | O(N²) | O(N²) | O(N) | O(1) | 穩定 |
插入排序 | O(N²) | O(N²) | O(N) | O(1) | 穩定 |
兩者在數據上的表現非常一致。但是插入排序可能因為循環不成立而退出,從而減少了比較的次數。因此一般情況下,插入排序是相比冒泡排序更優秀的排序算法,小數據量的情況下一般優先選擇插入排序。
希爾排序(Shellsort):
希爾排序的名稱來自他的發明者DonaldShell,希爾排序是第一批沖破二次時間屏障的算法之一。它的算法思路是通過比較一定距離的元素,逐漸縮減,直到比較距離為1的元素。所以希爾排序也叫縮減增量排序。
以下為希爾排序的一次范例:
原始數據 | 10 | 1 | 66 | 20 | 19 | 3 |
第1次排序(間距為3) | 10 | 1 | 3 | 20 | 19 | 66 |
第2次排序(間距為2) | 3 | 1 | 10 | 20 | 19 | 66 |
第3次排序(間距為1) | 1 | 3 | 10 | 19 | 20 | 66 |
第一趟排序間距為3,將10與20比較;1跟19比較;66跟3比較,交換66跟3的位置。
第二趟排序間距為2,將10與3比較,交換10個3的位置;20跟1比較;10跟19比較;66跟20比較;
第三趟排序間距為1,將1跟3比較,交換1跟3的位置;3跟10比較;10跟20比較;20跟19比較,交換19跟20的位置。19再跟10比較;20跟66比較;
以java代碼為例,進行編程:
1 public static void shellSorted(Integer[] list) { 2 for (int gap = 3; gap > 0; gap--) { 3 for (int i = gap; i < list.length; i++) { 4 Integer temp = list[i]; 5 int j; 6 for (j = i; j - gap >= 0 && list[j - gap] > temp; j -= gap) { 7 list[j] = list[j - gap]; 8 } 9 list[j] = temp; 10 } 11 } 12 }
分析:上述代碼中,gap為3、2、1,稱為增量序列。希爾排序的效率依賴於增量序列的選擇。希爾排序的問題在於,增量未必是互素的。
Hibbard提出一個增量序列,稱為Hibbard增量序列。Hibbard增量序列形如1、3、7、.....、2^k-1。因此增量之間沒有公因子,所以Hibbard增量的最壞運行情形時間為O(N^3/2),平均運行時間為O(N^5/4)。
Sedgewick提出的幾種增量序列,最壞運行時間為O(N^4/3),平均運行時間為O(N^7/6),其中最好的序列為{1,5,19,41,109....},該序列9*4^i-9*2^i+1,或者4^i-3*2^i+1的形式。
堆排序(Heapsort):
堆排序是一種使用堆為結構來進行排序的算法。利用二叉堆作為優先隊列,進行排序,每次獲取堆中最大值或者最小值,放入新序列中。實現了隊列的排序。
堆排序例子:
第一步獲取最大值66;
第二步獲取最大值20;
第三部獲取最大值19;
。。。。。
以上述方式獲取新的排序隊列。
以java代碼為例,進行編程:
1 private static int leftChild(int i) { 2 return 2 * i + 1; 3 } 4 5 private static void perDown(Integer[] list, int i, int n) { 6 int child; 7 Integer temp; 8 for (temp = list[i]; leftChild(i) < n; i = child) { 9 child = leftChild(i); 10 if (child != n - 1 && list[child] < list[child + 1]) { 11 child++; 12 } 13 if (temp < list[child]) { 14 list[i] = list[child]; 15 } else { 16 break; 17 } 18 } 19 list[i] = temp; 20 } 21 22 public static void heapSort(Integer[] list) { 23 Integer temp; 24 25 for (int i = list.length / 2 - 1; i >= 0; i--) { 26 perDown(list, i, list.length); 27 } 28 for (int i = list.length - 1; i > 0; i--) { 29 temp = list[0]; 30 list[0] = list[i]; 31 list[i] = temp; 32 perDown(list, 0, i); 33 } 34 }
分析:堆排序種,第一階段構建堆最多用到2N次比較,第二階段,第i次deleteMax最多用到2Logi次比較,總共最多2N*LogN-O(N)次比較。堆排序也是一個非常穩定的算法。他的比較平均只比最壞情況指出的情況略少。
歸並排序(mergeSort):
歸並排序的用法於上面幾種排序不同,歸並排序使用場景是存在已經排序完成的隊列A和隊列B,現要將已經排序完成A、B兩個隊列合並成隊列C;歸並排序以O(N*logN)的最壞情形時間運行,而使用的比較時間幾乎是最優的。它是遞歸算法的一個好的事例應用。
歸並排序例子:
A、B為已經排序的數組,C為新建數組。A、B數組一開始有個指針指向第一個元素。
第一步,比較A、B第一個元素1<2。將1寫入C中,A的指針往右移動。
第二部,比較2<4,將2寫入C中,B的指針往右移動。
.....
同上,A或者B全部寫入后,將另外的數組剩余的數字也寫入C中。
在單個數組進行排序的時候使用歸並排序,則是將單個數組拆分成兩個數組,將這兩個數組分別排序完成后,再進行歸並。
以java代碼為例,進行編程:
1 public static void mergeSort(Integer[] list) { 2 Integer[] tempArray = new Integer[list.length]; 3 mergeSort(list, tempArray, 0, list.length - 1); 4 } 5 6 private static void mergeSort(Integer[] list, Integer[] tempArray, int left, int right) { 7 if (left >= right) { 8 return; 9 } 10 int center = (left + right) / 2; 11 mergeSort(list, tempArray, left, center); 12 mergeSort(list, tempArray, center + 1, right); 13 merge(list, tempArray, left, center + 1, right); 14 } 15 16 private static void merge(Integer[] list, Integer[] tempArray, int leftPos, int rightPos, int rightEnd) { 17 int leftEnd = rightPos - 1; 18 int tempPos = leftPos; 19 int numberElements = rightEnd - leftPos + 1; 20 21 while (leftPos <= leftEnd && rightPos <= rightEnd) { 22 if (list[leftPos] <= list[rightPos]) { 23 tempArray[tempPos++] = list[leftPos++]; 24 } else { 25 tempArray[tempPos++] = list[rightPos++]; 26 } 27 } 28 29 while (leftPos <= leftEnd) { 30 tempArray[tempPos++] = list[leftPos++]; 31 } 32 33 while (rightPos <= rightEnd) { 34 tempArray[tempPos++] = list[rightPos++]; 35 } 36 37 for (int i = 0; i < numberElements; i++, rightEnd--) { 38 list[rightEnd] = tempArray[rightEnd]; 39 } 40 }
分析:歸並排序的運行時間為O(N*logN),但是歸並排序需要線性附加內存。整個算法在花費了數據拷貝再拷回來這些附加工作,減慢了排序速度。這種拷貝可以通過list和tempArray進行角色交換來避免。在java中歸並排序的數據移動是很省時的,因為歸並排序只是移動了對象的引用。在java類庫中泛型排序使用的就是歸並算法。
快速排序(mergeSort):
快速排序是實踐中的一種快速排序算法,在Java中對基本類型的排序特別有用,也是面試時候最容易被問道的排序算法。快排的平均運行時間為O(N*logN),最壞情形為O(N^2),但是經過稍許努力就能讓最壞情況很難出現。通過快排和堆排序進行結合,由於堆排序的最壞為O(N*logN),我們可以對幾乎所有的輸入都能達到快速排序的快速運行時間。
快排例子:
第一步,隨機獲取樞紐元,上例中為19。
第二步,將其他數字跟19做比較,一組比19小,一組等於19,一組大於19。
第三步,將大於19和小於19的兩組進行排序。
第四步,按照順序將他們寫入數組中。
以java代碼為例,進行編程:
1 public static void quickSort(Integer[] list) { 2 quickSort(list, 0, list.length - 1); 3 } 4 5 private static void quickSort(Integer[] list, int left, int right) { 6 if (left + CUTOFF <= right) { 7 Integer pivot = media3(list, left, right); 8 int i = left + 1, j = right - 2; 9 while (true) { 10 //=pivot,防止list[i]=list[j]=pivot,進入死循環 11 while (i<list.length-1&&list[i] <=pivot) { 12 i++; 13 } 14 while (j>0&&list[j] >= pivot) { 15 j--; 16 } 17 if (i < j) { 18 swapReferences(list, i, j); 19 }else { 20 break; 21 } 22 } 23 //分組完成再把中值拎回來放在中間 24 swapReferences(list, i, right - 1); 25 26 //防止數組中數字一樣進入死循環 27 if(left<i-1&&i-1<right){ 28 quickSort(list, left, i - 1); 29 } 30 if(left<i+1&&i+1<right){ 31 quickSort(list, i + 1, right); 32 } 33 } else { 34 insertionSorted(list, left, right); 35 } 36 } 37 38 private static Integer media3(Integer[] list, int left, int right) { 39 int center = (left + right) / 2; 40 if (list[center] < list[left]) { 41 swapReferences(list, left, center); 42 } 43 if (list[right] < list[left]) { 44 swapReferences(list, left, right); 45 } 46 if (list[right] < list[center]) { 47 swapReferences(list, center, right); 48 } 49 //先把中值拎出去,方便交換操作 50 swapReferences(list, center, right - 1); 51 return list[right - 1]; 52 } 53 54 private static void swapReferences(Integer[] list, int i, int j) { 55 Integer temp = list[i]; 56 list[i] = list[j]; 57 list[j] = temp; 58 }
分析:樞紐元的選擇對快排的效率有很大影響,隨機選取樞紐元是很比較靠譜的做法。比較不好的選取方式選取第一個或者最后一個,上述代碼的樞紐元用的三數中值分割法,取數組第一位,中間位,和最后一位,從三個數中去中間值作為樞紐元。因為快排是遞歸的分隔數組進行排序,當數組被切割得比較小的時候(N<=20),可以使用插入排序來完成,效率會更高。快速排序的最壞情況為O(N^2),最好情況為O(NlogN),平均情況也是O(NlogN)。
桶排序(bucketSorted):
當輸入的數據由僅小於M的正整數來組成,可以使用桶排序來完成。假設有一個存在一個整數數組,數組中的元素不超過10。對這個數組進行排序,創建一個長度為11的數組count,count中的每個元素都為0,當讀入數據為i的時候,count(i)加上1,等全部讀取完畢之后根據count數將數組排列出來。
桶排序例子:
原數組進行迭代,讀到的數據在在count數組中對應下標的地方進行+1操作。等原數組迭代完成后,將count數組讀出就是排序完成的數組。
java代碼如下:
1 public static void bucketSorted(Integer[] list) { 2 Integer[] count = new Integer[10]; 3 for (int i = 0; i < count.length; i++) { 4 count[i] = 0; 5 } 6 7 for (Integer value : list) { 8 count[value] += 1; 9 } 10 int i = 0; 11 for (int k = 0; k < count.length - 1; k++) { 12 for (int j = 1; j <= count[k]; j++) { 13 list[i++] = k; 14 } 15 } 16 }
分析:桶排序比較簡單,然而在業務中很少有機會能使用到。當你的數字M上限比較高的時候,可以使用基數排序來實現。算法的用時為O(M+N),M為桶的個數。如果M為O(N),則總時間為O(N)。
基數排序(radix sort):
基數排序又叫做卡片排序,假設我們有大小在0~999之間數據進行排序,如果使用桶排序就不大合適。此時我們就可以使用基數排序,按照個位、十位、百位數進行三次排序。
基數排序例子:
第一步,根據個位數排序,排序完成依次讀取。
第二部,根據十位數排序,排序完成依次讀取。
第三部,根據百位數排序,排序完成依次讀取。
由於每一次排序的過程都保留了上一次排序的順序,所以最后數字能按照大小進行排序。
以java代碼為例,進行編程:
1 public static void radixSort(Integer[] list, int numberLen) { 2 3 //初始化 4 final int BUCKETS = 10; 5 ArrayList<Integer>[] buckets = new ArrayList[BUCKETS]; 6 for (int i = 0; i < BUCKETS; i++) { 7 buckets[i] = new ArrayList<Integer>(); 8 } 9 10 //排序 11 for (int i = 0; i < numberLen; i++) { 12 13 for (Integer num : list) { 14 buckets[(int) ((num / Math.pow(10, i)) % 10)].add(num); 15 } 16 17 int j = 0; 18 for (ArrayList<Integer> bucket : buckets) { 19 for (Integer number : bucket) { 20 list[j++] = number; 21 } 22 bucket.clear(); 23 } 24 } 25 }
分析:基數排序的運行時間為O(p(N+b)),p是排序的趟數,對應上述代碼的numberLen。b為桶的個數,對應上述代碼中的BUCKETS。桶排序和基數排序實現了線性時間完成排序。但是桶排序和基數排序的條件相比其他排序苛刻。
計數基數排序(counting radix sort):
計數基數排序是基數排序的另外一個實現,他避免使用ArrayList,取而代之的是用一個計數器。當數組掃描的對應的數字的時候,計數器在對應的位置上+1。等所有排序完成之后,根據計算器的數據,將對應的數據翻出來。完成排序。
第一步,遍歷原數組,讀取到值之后,在對應的計數器上+1,比如讀取到5,則在計數器的5上加1。
第二部,將計數器上數據進行疊加,獲取對應數字的位置,計數器上1的位置上為0的數量+1的數量,2位置上的數量為0的數量+1的數量+2的數量。
第三部,根據計數器,重排排序數組。
以java代碼為例,進行編程:
1 public static Integer[] countingSort(Integer[] list) { 2 Integer max = getMax(list); 3 4 Integer min = getMin(list); 5 6 Integer[] result = new Integer[list.length]; 7 8 int k = max - min + 1; 9 int[] count = new int[k]; 10 for (int i = 0; i < list.length; ++i) { 11 count[list[i] - min] += 1; 12 } 13 for (int i = 1; i < count.length; ++i) { 14 count[i] = count[i] + count[i - 1]; 15 } 16 for (int i = list.length - 1; i >= 0; --i) { 17 result[--count[list[i] - min]] = list[i];//按存取的方式取出c的元素 18 } 19 return result; 20 } 21 22 private static Integer getMax(Integer[] list) { 23 int b[] = new int[list.length]; 24 int max = list[0]; 25 for (int i : list) { 26 if (i > max) { 27 max = i; 28 } 29 } 30 return max; 31 } 32 33 private static Integer getMin(Integer[] list) { 34 int b[] = new int[list.length]; 35 int min = list[0]; 36 for (int i : list) { 37 if (i < min) { 38 min = i; 39 } 40 } 41 return min; 42 }
分析:計數排序也可以用來做字符串的排序。但是和桶排序有一個共同的問題是,當要進行排序的數字之間跨度非常大,或者字符串排序中字符串的長度特別長的時候,該算法的優勢就沒有那么明顯,甚至完全消失。
外部排序(external sorting):
以上的各種算法,都是將要排序的數組放到內存之中進行排序,但是在一些特殊場景下,需要被排序的內容數量太大,而沒辦法裝載入內存的時候,我們就需要用到外部排序算法。下面簡單介紹一種在磁盤上進行外部排序的算法,因為在磁帶上訪問一個元素需要把磁帶轉到正確的位置上,因此磁帶需要被連續訪問才能提高效率。我們通過部分排序的方式,在磁帶上順序得讀入數據和寫出數據來完成排序。假設現在我們有4個磁帶,a1,a2,b1,b2,內存一次可以容納3個數據,見下方模型:
1、一開始數據並無排序,因為內存每次可以讀入3個數據,每三個數據進行一次排序,保存12個數據中,有四隊有序數據,b1兩組,b2有兩組。
2、使用歸並排序的套路,磁盤的磁頭分別指向兩組數據的開頭部分,讀入后進行比較,然后按順序寫入a1中。第三組數據和第四組數據寫入a2中。
3、跟上述方式一樣,磁頭指向a1、a2的開始部分,然后將比較后的的數據寫入b1中。
排序完成。
分析:上方的模型比較簡單,當我們有更多磁帶,就可以進行多路合並,即同時比較多組數據來減少數據寫入寫出的次數。但是通過增加磁帶來減少讀寫次數並不方便,還可以通過多相合並,即通過分配不同磁帶的不同順串的數量來完成上述工作。
總結:排序是最古老,被研究得最完備的問題之一。對於大部分內部排序的應用,不是插入、希爾、歸並、就是快排。就主要取決於輸入數量的大小和底層環境決定的。插入排序適用於非常少量的輸入。中等規模用希爾是個不錯的選擇,只要增量序列合適,就能表現優異。歸並排序最壞情況為O(NLogN),但是卻需要額外的空間,但是比較的次數幾乎是最優的。快排並不保證提供最壞的時間復雜度,並且編程麻煩。但是幾乎可以肯定做到O(N logN),配合堆排序,就可以保證最壞情況為O(N logN)。基數排序可以在線性時間內排序,在特定環境下是相對比較的排序法更加實用。而外部排序則處理數據量大無法完全放入內存的情況。