1. 程式人生 > 程式設計 >javascript中可能用得到的全部的排序演算法

javascript中可能用得到的全部的排序演算法

導讀

排序演算法可以稱得上是我的盲點,曾幾何時當我知道Chrome的Array.prototype.sort使用了快速排序時,我的內心是奔潰的(啥是快排,我只知道冒泡啊?!),要知道學習一門技術最好的時間是三年前,但願我現在補習還來得及(捂臉).

因此本篇重拾了出鏡概率比較高的十來種排序演算法,逐一分析其排序思想,並批註注意事項. 歡迎對演算法提出改進和討論.

氣泡排序

javascript中可能用得到的全部的排序演算法

氣泡排序需要兩個巢狀的迴圈. 其中,外層迴圈移動遊標; 內層迴圈遍歷遊標及之後(或之前)的元素,通過兩兩交換的方式,每次只確保該內迴圈結束位置排序正確,然後內層迴圈週期結束,交由外層迴圈往後(或前)移動遊標,隨即開始下一輪內層迴圈,以此類推,直至迴圈結束.

Tips: 由於氣泡排序只在相鄰元素大小不符合要求時才調換他們的位置,它並不改變相同元素之間的相對順序,因此它是穩定的排序演算法.

由於有兩層迴圈,因此可以有四種實現方式.

方案 外層迴圈 內層迴圈
1 正序 正序
2 正序 逆序
3 逆序 正序
4 逆序 逆序

四種不同迴圈方向,實現方式略有差異.

如下是動圖效果(對應於方案1: 內/外層迴圈均是正序遍歷.

氣泡排序

如下是上圖的演算法實現(對應方案一: 內/外層迴圈均是正序遍歷).

//先將交換元素部分抽象出來
function swap(i,j,array){
 var temp = array[j];
 array[j] = array[i];
 array[i] = temp;
}
function bubbleSort(array) {
 var length = array.length,isSwap;
 for (var i = 0; i < length; i++) { //正序
 isSwap = false;
 for (var j = 0; j < length - 1 - i; j++) { //正序
 array[j] > array[j+1] && (isSwap = true) && swap(j,j+1,array);
 }
 if(!isSwap)
 break;
 }
 return array;
}

以上,排序的特點就是: 靠後的元素位置先確定.

方案二: 外迴圈正序遍歷,內迴圈逆序遍歷,程式碼如下:

function bubbleSort(array) {
 var length = array.length,isSwap;
 for (var i = 0; i < length; i++) { //正序
 isSwap = false;
 for (var j = length - 1; j >= i+1; j--) { //逆序
 array[j] < array[j-1] && (isSwap = true) && swap(j,j-1,靠前的元素位置先確定.

方案三: 外迴圈逆序遍歷,內迴圈正序遍歷,isSwap; for (var i = length - 1; i >= 0; i--) { //逆序 isSwap = false; for (var j = 0; j < i; j++) { //正序 array[j] > array[j+1] && (isSwap = true) && swap(j,由於內迴圈是正序遍歷,因此靠後的元素位置先確定.

方案四: 外迴圈逆序遍歷,isSwap; for (var i = length - 1; i >= 0; i--) { //逆序 isSwap = false; for (var j = length - 1; j >= length - 1 - i; j--) { //逆序 array[j] < array[j-1] && (isSwap = true) && swap(j,由於內迴圈是逆序遍歷,因此靠前的元素位置先確定.

以下是其演算法複雜度:

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(n²) O(n) O(n²) O(1)

氣泡排序是最容易實現的排序,最壞的情況是每次都需要交換,共需遍歷並交換將近n²/2次,時間複雜度為O(n²). 最佳的情況是內迴圈遍歷一次後發現排序是對的,因此退出迴圈,時間複雜度為O(n). 平均來講,時間複雜度為O(n²). 由於氣泡排序中只有快取的temp變數需要記憶體空間,因此空間複雜度為常量O(1).

雙向氣泡排序

雙向氣泡排序是氣泡排序的一個簡易升級版,又稱雞尾酒排序. 氣泡排序是從低到高(或者從高到低)單向排序,雙向氣泡排序顧名思義就是從兩個方向分別排序(通常,先從低到高,然後從高到低). 因此它比氣泡排序效能稍好一些.

如下是演算法實現:

function bothwayBubbleSort(array){
 var tail = array.length-1,i,isSwap = false;
 for(i = 0; i < tail; tail--){
 for(var j = tail; j > i; j--){ //第一輪,先將最小的資料冒泡到前面
 array[j-1] > array[j] && (isSwap = true) && swap(j,array);
 }
 i++;
 for(j = i; j < tail; j++){ //第二輪,將最大的資料冒泡到後面
 array[j] > array[j+1] && (isSwap = true) && swap(j,array);
 }
 }
 return array;
}

選擇排序

從演算法邏輯上看,選擇排序是一種簡單且直觀的排序演算法. 它也是兩層迴圈. 內層迴圈就像工人一樣,它是真正做事情的,內層迴圈每執行一遍,將選出本次待排序的元素中最小(或最大)的一個,存放在陣列的起始位置. 而 外層迴圈則像老闆一樣,它告訴內層迴圈你需要不停的工作,直到工作完成(也就是全部的元素排序完成).

Tips: 選擇排序每次交換的元素都有可能不是相鄰的,因此它有可能打破原來值為相同的元素之間的順序. 比如陣列[2,2,1,3],正向排序時,第一個數字2將與數字1交換,那麼兩個數字2之間的順序將和原來的順序不一致,雖然它們的值相同,但它們相對的順序卻發生了變化. 我們將這種現象稱作 不穩定性 .

如下是動圖效果:

選擇排序

如下是上圖的演算法實現:

function selectSort(array) {
 var length = array.length,min;
 for (var i = 0; i < length - 1; i++) {
 min = i;
 for (var j = i + 1; j < length; j++) {
 array[j] < array[min] && (min = j); //記住最小數的下標
 }
 min!=i && swap(i,min,array);
 }
 return array;
}

以下是其演算法複雜度:

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(n²) O(n²) O(n²) O(1)

選擇排序的簡單和直觀名副其實,這也造就了它”出了名的慢性子”,無論是哪種情況,哪怕原陣列已排序完成,它也將花費將近n²/2次遍歷來確認一遍. 即便是這樣,它的排序結果也還是不穩定的. 唯一值得高興的是,它並不耗費額外的記憶體空間.

插入排序

插入排序的設計初衷是往有序的陣列中快速插入一個新的元素. 它的演算法思想是: 把要排序的陣列分為了兩個部分,一部分是陣列的全部元素(除去待插入的元素),另一部分是待插入的元素; 先將第一部分排序完成,然後再插入這個元素. 其中第一部分的排序也是通過再次拆分為兩部分來進行的.

插入排序由於操作不盡相同,可分為 直接插入排序,折半插入排序(又稱二分插入排序),連結串列插入排序,希爾排序 .

直接插入排序

它的基本思想是: 將待排序的元素按照大小順序,依次插入到一個已經排好序的陣列之中,直到所有的元素都插入進去.

如下是動圖效果:

直接插入排序

如下是上圖的演算法實現:

function directInsertionSort(array) {
 var length = array.length,index,current;
 for (var i = 1; i < length; i++) {
 index = i - 1; //待比較元素的下標
 current = array[i]; //當前元素
 while(index >= 0 && array[index] > current) { //前置條件之一:待比較元素比當前元素大
 array[index+1] = array[index]; //將待比較元素後移一位
 index--;  //遊標前移一位
 //console.log(array);
 }
 if(index+1 != i){  //避免同一個元素賦值給自身
 array[index+1] = current; //將當前元素插入預留空位
 //console.log(array);
 } 
 }
 return array;
}

為了更好的觀察到直接插入排序的實現過程,我們不妨將上述程式碼中的註釋部分加入. 以陣列 [5,4,3,1] 為例,如下便是原陣列的演化過程.

javascript中可能用得到的全部的排序演算法

可見,陣列的各個元素,從後往前,只要比前面的元素小,都依次插入到了合理的位置.

Tips: 由於直接插入排序每次只移動一個元素的位置,並不會改變值相同的元素之間的排序,因此它是一種穩定排序.

折半插入排序

折半插入排序是直接插入排序的升級版. 鑑於插入排序第一部分為已排好序的陣列,我們不必按順序依次尋找插入點,只需比較它們的中間值與待插入元素的大小即可.

Tips: 同直接插入排序類似,折半插入排序每次交換的是相鄰的且值為不同的元素,它並不會改變值相同的元素之間的順序. 因此它是穩定的.

演算法基本思想是:

  1. 取0 ~ i-1的中間點(m = (i-1)>>1),array[i] 與 array[m] 進行比較,若array[i] < array[m],則說明待插入的元素array[i] 應該處於陣列的 0 ~ m 索引之間; 反之,則說明它應該處於陣列的 m ~ i-1 索引之間.
  2. 重複步驟1,每次縮小一半的查詢範圍,直至找到插入的位置.
  3. 將陣列中插入位置之後的元素全部後移一位.
  4. 在指定位置插入第 i 個元素.

注: x>>1 是位運算中的右移運算,表示右移一位,等同於x除以2再取整,即 x>>1 == Math.floor(x/2) .

如下是演算法實現:

function binaryInsertionSort(array){
 var current,low,high,m;
 for(i = 1; i < array.length; i++){
 low = 0;
 high = i - 1;
 current = array[i];

 while(low <= high){ //步驟1&2:折半查詢
 m = (low + high)>>1;
 if(array[i] >= array[m]){//值相同時,切換到高半區,保證穩定性
 low = m + 1; //插入點在高半區
 }else{
 high = m - 1; //插入點在低半區
 }
 }
 for(j = i; j > low; j--){ //步驟3:插入位置之後的元素全部後移一位
 array[j] = array[j-1];
 }
 array[low] = current; //步驟4:插入該元素
 }
 return array;
}

為了便於對比,同樣以陣列 [5,1] 舉例🌰. 原陣列的演化過程如下(與上述一樣):

折半插入排序

雖然折半插入排序明顯減少了查詢的次數,但是陣列元素移動的次數卻沒有改變. 它們的時間複雜度都是O(n²).

希爾排序

希爾排序也稱縮小增量排序,它是直接插入排序的另外一個升級版,實質就是分組插入排序. 希爾排序以其設計者希爾(Donald Shell)的名字命名,並於1959年公佈.

演算法的基本思想:

  1. 將陣列拆分為若干個子分組,每個分組由相距一定”增量”的元素組成. 比方說將[0,5,6,7,8,9,10]的陣列拆分為”增量”為5的分組,那麼子分組分別為 [0,5],[1,6],[2,7],[3,8],[4,9] 和 [5,10].
  2. 然後對每個子分組應用直接插入排序.
  3. 逐步減小”增量”,重複步驟1,2.
  4. 直至”增量”為1,這是最後一個排序,此時的排序,也就是對全陣列進行直接插入排序.

如下是排序的示意圖:

希爾排序示意圖

可見,希爾排序實際上就是不斷的進行直接插入排序,分組是為了先將區域性元素有序化. 因為直接插入排序在元素基本有序的狀態下,效率非常高. 而希爾排序呢,通過先分組後排序的方式,製造了直接插入排序高效執行的場景. 因此希爾排序效率更高.

我們試著抽象出共同點,便不難發現上述希爾排序的第四步就是一次直接插入排序,而希爾排序原本就是從”增量”為n開始,直至”增量”為1,迴圈應用直接插入排序的一種封裝. 因此直接插入排序就可以看做是步長為1的希爾排序. 為此我們先來封裝下直接插入排序.

//形參增加步數gap(實際上就相當於gap替換了原來的數字1)
function directInsertionSort(array,gap) {
 gap = (gap == undefined) ? 1 : gap; //預設從下標為1的元素開始遍歷
 var length = array.length,current;
 for (var i = gap; i < length; i++) {
 index = i - gap; //待比較元素的下標
 current = array[i]; //當前元素
 while(index >= 0 && array[index] > current) { //前置條件之一:待比較元素比當前元素大
 array[index + gap] = array[index]; //將待比較元素後移gap位
 index -= gap;  //遊標前移gap位
 }
 if(index + gap != i){  //避免同一個元素賦值給自身
 array[index + gap] = current; //將當前元素插入預留空位
 }
 }
 return array;
}

那麼希爾排序的演算法實現如下:

function shellSort(array){
 var length = array.length,gap = length>>1,current,j;
 while(gap > 0){
 directInsertionSort(array,gap); //按指定步長進行直接插入排序
 gap = gap>>1;
 }
 return array;
}

同樣以陣列[5,1] 舉例🌰. 原陣列的演化過程如下:

希爾排序

對比上述直接插入排序和折半插入排序,陣列元素的移動次數由14次減少為7次. 通過拆分原陣列為粒度更小的子陣列,希爾排序進一步提高了排序的效率.

不僅如此,以上步長設定為了 {N/2,(N/2)/2,…,1}. 該序列即希爾增量,其它的增量序列 還有Hibbard:{1,2^k-1}. 通過合理調節步長,還能進一步提升排序效率. 實際上已知的最好步長序列是由Sedgewick提出的(1,19,41,109,…). 該序列中的項或者是9*4^i - 9*2^i + 1或者是4^i - 3*2^i + 1. 具體請戳希爾排序-維基百科.

Tips: 我們知道,單次直接插入排序是穩定的,它不會改變相同元素之間的相對順序,但在多次不同的插入排序過程中,相同的元素可能在各自的插入排序中移動,可能導致相同元素相對順序發生變化. 因此,希爾排序並不穩定.

歸併排序

歸併排序建立在歸併操作之上,它採取分而治之的思想,將陣列拆分為兩個子陣列,分別排序,最後才將兩個子數組合並; 拆分的兩個子陣列,再繼續遞迴拆分為更小的子陣列,進而分別排序,直到陣列長度為1,直接返回該陣列為止.

Tips: 歸併排序嚴格按照從左往右(或從右往左)的順序去合併子陣列,它並不會改變相同元素之間的相對順序,因此它也是一種穩定的排序演算法.

如下是動圖效果:

歸併排序

歸併排序可通過兩種方式實現:

  1. 自上而下的遞迴
  2. 自下而上的迭代

如下是演算法實現(方式1:遞迴):

function mergeSort(array) { //採用自上而下的遞迴方法
 var length = array.length;
 if(length < 2) {
 return array;
 }
 var m = (length >> 1),left = array.slice(0,m),right = array.slice(m); //拆分為兩個子陣列
 return merge(mergeSort(left),mergeSort(right));//子陣列繼續遞迴拆分,然後再合併
}
function merge(left,right){ //合併兩個子陣列
 var result = [];
 while (left.length && right.length) {
 var item = left[0] <= right[0] ? left.shift() : right.shift();//注意:判斷的條件是小於或等於,如果只是小於,那麼排序將不穩定.
 result.push(item);
 }
 return result.concat(left.length ? left : right);
}

由上,長度為n的陣列,最終會呼叫mergeSort函式2n-1次. 通過自上而下的遞迴實現的歸併排序,將存在堆疊溢位的風險. 親測各瀏覽器的堆疊溢位所需的遞迴呼叫次數大致為:

Chrome v55: 15670
Firefox v50: 44488
Safari v9.1.2: 50755

以下是測試程式碼:

function computeMaxCallStackSize() {
 try {
 return 1 + computeMaxCallStackSize();
 } catch (e) {
 // Call stack overflow
 return 1;
 }
}
var time = computeMaxCallStackSize();
console.log(time);

為此,ES6規範中提出了尾調優化的思想: 如果一個函式的最後一步也是一個函式呼叫,那麼該函式所需要的棧空間將被釋放,它將直接進入到下次呼叫中,最終呼叫棧裡只保留最後一次的呼叫記錄.

雖然ES6規範如此誘人,然而目前並沒有瀏覽器支援尾調優化,相信在不久的將來,尾調優化就會得到主流瀏覽器的支援.

以下是其演算法複雜度:

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(nlog₂n) O(nlog₂n) O(nlog₂n) O(n)

從效率上看,歸併排序可算是排序演算法中的”佼佼者”. 假設陣列長度為n,那麼拆分陣列共需logn步,又每步都是一個普通的合併子陣列的過程,時間複雜度為O(n),故其綜合時間複雜度為O(nlogn). 另一方面,歸併排序多次遞迴過程中拆分的子陣列需要儲存在記憶體空間,其空間複雜度為O(n).

快速排序

快速排序借用了分治的思想,並且基於氣泡排序做了改進. 它由C. A. R. Hoare在1962年提出. 它將陣列拆分為兩個子陣列,其中一個子陣列的所有元素都比另一個子陣列的元素小,然後對這兩個子陣列再重複進行上述操作,直到陣列不可拆分,排序完成.

如下是動圖效果:

快速排序

如下是演算法實現:

function quickSort(array,left,right) {
 var partitionIndex,left = typeof left == 'number' ? left : 0,right = typeof right == 'number' ? right : array.length-1;
 if (left < right) {
 partitionIndex = partition(array,right);//切分的基準值
 quickSort(array,partitionIndex-1);
 quickSort(array,partitionIndex+1,right);
 }
 return array;
}
function partition(array,right) { //分割槽操作
 for (var i = left+1,j = left; i <= right; i++) {//j是較小值儲存位置的遊標
 array[i] < array[left] && swap(i,++j,array);//以第一個元素為基準
 }
 swap(left,array); //將第一個元素移至中間
 return j;
}

以下是其演算法複雜度:

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(nlog₂n) O(nlog₂n) O(n²) O(nlog₂n)

快速排序排序效率非常高. 雖然它執行最糟糕時將達到O(n²)的時間複雜度,但通常,平均來看,它的時間複雜為O(nlogn),比同樣為O(nlogn)時間複雜度的歸併排序還要快. 快速排序似乎更偏愛亂序的數列,越是亂序的數列,它相比其他排序而言,相對效率更高. 之前在 捋一捋JS的陣列 一文中就提到: Chrome的v8引擎為了高效排序,在排序資料超過了10條時,便會採用快速排序. 對於10條及以下的資料採用的便是插入排序.

Tips: 同選擇排序相似,快速排序每次交換的元素都有可能不是相鄰的,因此它有可能打破原來值為相同的元素之間的順序. 因此,快速排序並不穩定.

堆排序

1991年的計算機先驅獎獲得者、斯坦福大學計算機科學系教授羅伯特·弗洛伊德(Robert W.Floyd) 和威廉姆斯(J.Williams) 在1964年共同發明了著名的堆排序演算法(Heap Sort).

堆排序是利用堆這種資料結構所設計的一種排序演算法. 它是選擇排序的一種. 堆分為大根堆和小根堆. 大根堆要求每個子節點的值都不大於其父節點的值,即array[childIndex] <= array[parentIndex],最大的值一定在堆頂. 小根堆與之相反,即每個子節點的值都不小於其父節點的值,最小的值一定在堆頂. 因此我們可使用大根堆進行升序排序,使用小根堆進行降序排序.

並非所有的序列都是堆,對於序列k1,k2,…kn,需要滿足如下條件才行:

ki <= k(2i) 且 ki<=k(2i+1)(1≤i≤ n/2),即為小根堆,將<=換成>=,那麼則是大根堆. 我們可以將這裡的堆看作完全二叉樹,k(i) 相當於是二叉樹的非葉子節點,k(2i) 則是左子節點,k(2i+1)是右子節點.

演算法的基本思想(以大根堆為例):

  1. 先將初始序列K[1..n]建成一個大根堆,此堆為初始的無序區.
  2. 再將關鍵字最大的記錄K1(即堆頂)和無序區的最後一個記錄K[n]交換,由此得到新的無序區K[1..n-1]和有序區K[n],且滿足K[1..n-1].keys≤K[n].key
  3. 交換K1和 K[n] 後,堆頂可能違反堆性質,因此需將K[1..n-1]調整為堆. 然後重複步驟2,直到無序區只有一個元素時停止.

如下是動圖效果:

javascript中可能用得到的全部的排序演算法

如下是演算法實現:

function heapAdjust(array,length) {//堆調整
 var left = 2 * i + 1,right = 2 * i + 2,largest = i;
 if (left < length && array[largest] < array[left]) {
 largest = left;
 }
 if (right < length && array[largest] < array[right]) {
 largest = right;
 }
 if (largest != i) {
 swap(i,largest,array);
 heapAdjust(array,length);
 }
}
function heapSort(array) {
 //建立大頂堆
 length = array.length;
 for (var i = length>>1; i >= 0; i--) {
 heapAdjust(array,length);
 }
 //調換第一個與最後一個元素,重新調整為大頂堆
 for (var i = length - 1; i > 0; i--) {
 swap(0,--length);
 }
 return array;
}

以上,①建立堆的過程,從length/2 一直處理到0,時間複雜度為O(n);

②調整堆的過程是沿著堆的父子節點進行調整,執行次數為堆的深度,時間複雜度為O(lgn);

③堆排序的過程由n次第②步完成,時間複雜度為O(nlgn).

Tips: 由於堆排序中初始化堆的過程比較次數較多,因此它不太適用於小序列. 同時由於多次任意下標相互交換位置,相同元素之間原本相對的順序被破壞了,因此,它是不穩定的排序.

計數排序

計數排序幾乎是唯一一個不基於比較的排序演算法,該演算法於1954年由 Harold H. Seward 提出. 使用它處理一定範圍內的整數排序時,時間複雜度為O(n+k),其中k是整數的範圍,它幾乎比任何基於比較的排序演算法都要快( 只有當O(k)>O(n*log(n))的時候其效率反而不如基於比較的排序,如歸併排序和堆排序).

使用計數排序需要滿足如下條件:

  • 待排序的序列全部為整數
  • 排序需要額外的儲存空間

演算法的基本思想:

計數排序利用了一個特性,對於陣列的某個元素,一旦知道了有多少個其它元素比它小(假設為m個),那麼就可以確定出該元素的正確位置(第m+1位)

  1. 獲取待排序陣列A的最大值,最小值.
  2. 將最大值與最小值的差值+1作為長度新建計數陣列B,並將相同元素的數量作為值存入計數陣列.
  3. 對計數陣列B累加計數,儲存不同值的初始下標.
  4. 從原陣列A挨個取值,賦值給一個新的陣列C相應的下標,最終返回陣列C.

注意: 如果原陣列A是包含若干個物件的陣列,需要基於物件的某個屬性進行排序,那麼演算法開始時,需要將原陣列A處理為一個只包含物件屬性值的簡單陣列simpleA,接下來便基於simpleA進行計數、累加計數,其它同上.

如下是動圖效果:

計數排序

如下是演算法實現:

function countSort(array,keyName){
 var length = array.length,output = new Array(length),max,simpleArray = keyName ? array.map(function(v){
 return v[keyName];
 }) : array; // 如果keyName是存在的,那麼就建立一個只有keyValue的簡單陣列

 // 獲取最大最小值
 max = min = simpleArray[0];
 simpleArray.forEach(function(v){
 v > max && (max = v);
 v < min && (min = v);
 });
 // 獲取計數陣列的長度
 var k = max - min + 1;
 // 新建並初始化計數陣列
 var countArray = new Array(k);
 simpleArray.forEach(function(v){
 countArray[v - min]= (countArray[v - min] || 0) + 1;
 });
 // 累加計數,儲存不同值的初始下標
 countArray.reduce(function(prev,arr){
 arr[i] = prev;
 return prev + current;
 },0);
 // 從原陣列挨個取值(因取的是原陣列的相應值,只能通過遍歷原陣列來實現)
 simpleArray.forEach(function(v,i){
 var j = countArray[v - min]++;
 output[j] = array[i];
 });
 return output;
}

以上實現不僅支援了數值序列的排序,還支援根據物件的某個屬性值來排序。測試如下:

var a = [2,2],b = [
 {id: 2,s:'a'},{id: 1,s: 'b'},s: 'c'},{id: 3,s: 'd'},{id: 2,s: 'e'},s: 'f'},{id: 4,s: 'g'},s: 'h'}
 ];
countSort(a); // [1,4]
countSort(b,'id'); // [{id:1,s:'b'},{id:1,s:'c'},s:'f'},{id:2,s:'e'},s:'h'},{id:3,s:'d'},{id:4,s:'g'}]

Tips: 計數排序不改變相同元素之間原本相對的順序,因此它是穩定的排序演算法.

桶排序

桶排序即所謂的箱排序,它是將陣列分配到有限數量的桶子裡. 每個桶裡再各自排序(因此有可能使用別的排序演算法或以遞迴方式繼續桶排序). 當每個桶裡的元素個數趨於一致時,桶排序只需花費O(n)的時間. 桶排序通過空間換時間的方式提高了效率,因此它需要額外的儲存空間(即桶的空間).

演算法的基本思想:

桶排序的核心就在於怎麼把元素平均分配到每個桶裡,合理的分配將大大提高排序的效率.

如下是演算法實現:

function bucketSort(array,bucketSize) {
 if (array.length === 0) {
 return array;
 }

 var i = 1,min = array[0],max = min;
 while (i++ < array.length) {
 if (array[i] < min) {
 min = array[i]; //輸入資料的最小值
 } else if (array[i] > max) {
 max = array[i]; //輸入資料的最大值
 }
 }

 //桶的初始化
 bucketSize = bucketSize || 5; //設定桶的預設大小為5
 var bucketCount = ~~((max - min) / bucketSize) + 1,//桶的個數
 buckets = new Array(bucketCount); //建立桶
 for (i = 0; i < buckets.length; i++) {
 buckets[i] = []; //初始化桶
 }

 //將資料分配到各個桶中,這裡直接按照資料值的分佈來分配,一定範圍內均勻分佈的資料效率最為高效
 for (i = 0; i < array.length; i++) {
 buckets[~~((array[i] - min) / bucketSize)].push(array[i]);
 }

 array.length = 0;
 for (i = 0; i < buckets.length; i++) {
 quickSort(buckets[i]); //對每個桶進行排序,這裡使用了快速排序
 for (var j = 0; j < buckets[i].length; j++) {
 array.push(buckets[i][j]); //將已排序的資料寫回陣列中
 }
 }
 return array;
}

Tips: 桶排序本身是穩定的排序,因此它的穩定性與桶內排序的穩定性保持一致.

實際上,桶也只是一個抽象的概念,它的思想與歸併排序,快速排序等類似,都是通過將大量資料分配到N個不同的容器中,最後再合併資料. 這種方式大大減少了排序時整體的遍歷次數,提高了演算法效率.

基數排序

基數排序源於老式穿孔機,排序器每次只能看到一個列. 它是基於元素值的每個位上的字元來排序的. 對於數字而言就是分別基於個位,十位,百位 或千位等等數字來排序. (不明白不要緊,我也不懂,請接著往下讀)

按照優先從高位或低位來排序有兩種實現方案:

  • MSD: 由高位為基底,先按k1排序分組,同一組中記錄,關鍵碼k1相等,再對各組按k2排序分成子組,之後,對後面的關鍵碼繼續這樣的排序分組,直到按最次位關鍵碼kd對各子組排序後. 再將各組連線起來,便得到一個有序序列. MSD方式適用於位數多的序列.
  • LSD: 由低位為基底,先從kd開始排序,再對kd-1進行排序,依次重複,直到對k1排序後便得到一個有序序列. LSD方式適用於位數少的序列.

如下是LSD的動圖效果:

基數排序

如下是演算法實現:

function radixSort(array,max) {
 var buckets = [],unit = 10,base = 1;
 for (var i = 0; i < max; i++,base *= 10,unit *= 10) {
 for(var j = 0; j < array.length; j++) {
 var index = ~~((array[j] % unit) / base);//依次過濾出個位,十位等等數字
 if(buckets[index] == null) {
 buckets[index] = []; //初始化桶
 }
 buckets[index].push(array[j]);//往不同桶裡新增資料
 }
 var pos = 0,value;
 for(var j = 0,length = buckets.length; j < length; j++) {
 if(buckets[j] != null) {
 while ((value = buckets[j].shift()) != null) {
  array[pos++] = value; //將不同桶裡資料挨個撈出來,為下一輪高位排序做準備,由於靠近桶底的元素排名靠前,因此從桶底先撈
 }
 }
 }
 }
 return array;
}

以上演算法,如果用來比較時間,先按日排序,再按月排序,最後按年排序,僅需排序三次.

基數排序更適合用於對時間,字串等這些整體權值未知的資料進行排序.

Tips: 基數排序不改變相同元素之間的相對順序,因此它是穩定的排序演算法.

小結

各種排序效能對比如下:

排序型別 平均情況 最好情況 最壞情況 輔助空間 穩定性
氣泡排序 O(n²) O(n) O(n²) O(1) 穩定
選擇排序 O(n²) O(n²) O(n²) O(1) 不穩定
直接插入排序 O(n²) O(n) O(n²) O(1) 穩定
折半插入排序 O(n²) O(n) O(n²) O(1) 穩定
希爾排序 O(n^1.3) O(nlogn) O(n²) O(1) 不穩定
歸併排序 O(nlog₂n) O(nlog₂n) O(nlog₂n) O(n) 穩定
快速排序 O(nlog₂n) O(nlog₂n) O(n²) O(nlog₂n) 不穩定
堆排序 O(nlog₂n) O(nlog₂n) O(nlog₂n) O(1) 不穩定
計數排序 O(n+k) O(n+k) O(n+k) O(k) 穩定
桶排序 O(n+k) O(n+k) O(n²) O(n+k) (不)穩定
基數排序 O(d(n+k)) O(d(n+k)) O(d(n+kd)) O(n+kd) 穩定

注: 桶排序的穩定性取決於桶內排序的穩定性,因此其穩定性不確定. 基數排序中,k代表關鍵字的基數,d代表長度,n代表關鍵字的個數.

願以此文懷念下我那遠去的演算法課程.

未完待續…

感謝http://visualgo.net/提供圖片支援. 特別感謝不是小羊的肖恩在簡書上釋出的JS家的排序演算法提供的講解.