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

路易斯發表於2017-03-16

本篇有7k+字, 系統梳理了js中排序演算法相關的知識, 希望您能喜歡.

原文: louiszhai.github.io/2016/12/23/…

導讀

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

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

氣泡排序

JS中可能用得到的全部的排序演算法
冒泡

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

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

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

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

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

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

JS中可能用得到的全部的排序演算法
氣泡排序

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

//先將交換元素部分抽象出來
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,array);
    }
    if(!isSwap)
      break;
  }
  return array;
}複製程式碼

以上, 靠前的元素位置先確定.

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

function bubbleSort(array) {
  var length = array.length, 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,j+1,array);
    }
    if(!isSwap)
      break;
  }
  return array;
}複製程式碼

以上, 由於內迴圈是正序遍歷, 因此靠後的元素位置先確定.

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

function bubbleSort(array) {
  var length = array.length, 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,j-1,array);
    }
    if(!isSwap)
      break;
  }
  return array;
}複製程式碼

以上, 由於內迴圈是逆序遍歷, 因此靠前的元素位置先確定.

以下是其演算法複雜度:

平均時間複雜度 最好情況 最壞情況 空間複雜度
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,j-1,array);
    }
    i++;
    for(j = i; j < tail; j++){        //第二輪, 將最大的資料冒泡到後面
      array[j] > array[j+1] && (isSwap = true) && swap(j,j+1,array);
    }
  }
  return array;
}複製程式碼

選擇排序

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

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

如下是動圖效果:

JS中可能用得到的全部的排序演算法
選擇排序

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

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次遍歷來確認一遍. 即便是這樣, 它的排序結果也還是不穩定的. 唯一值得高興的是, 它並不耗費額外的記憶體空間.

插入排序

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

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

直接插入排序

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

如下是動圖效果:

JS中可能用得到的全部的排序演算法
直接插入排序

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

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,2,1] 為例, 如下便是原陣列的演化過程.

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

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

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, i, j, 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,4,3,2,1] 舉例?. 原陣列的演化過程如下(與上述一樣):

JS中可能用得到的全部的排序演算法
折半插入排序

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

希爾排序

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

演算法的基本思想:

  1. 將陣列拆分為若干個子分組, 每個分組由相距一定"增量"的元素組成. 比方說將[0,1,2,3,4,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, 這是最後一個排序, 此時的排序, 也就是對全陣列進行直接插入排序.

如下是排序的示意圖:

JS中可能用得到的全部的排序演算法
希爾排序示意圖

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

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

//形參增加步數gap(實際上就相當於gap替換了原來的數字1)
function directInsertionSort(array, gap) {
  gap = (gap == undefined) ? 1 : gap;       //預設從下標為1的元素開始遍歷
  var length = array.length, index, 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, i, j;
  while(gap > 0){
    directInsertionSort(array, gap); //按指定步長進行直接插入排序
    gap = gap>>1;
  }
  return array;
}複製程式碼

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

JS中可能用得到的全部的排序演算法
希爾排序

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

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

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

歸併排序

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

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

如下是動圖效果:

JS中可能用得到的全部的排序演算法
歸併排序

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

  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年提出. 它將陣列拆分為兩個子陣列, 其中一個子陣列的所有元素都比另一個子陣列的元素小, 然後對這兩個子陣列再重複進行上述操作, 直到陣列不可拆分, 排序完成.

如下是動圖效果:

JS中可能用得到的全部的排序演算法
快速排序

如下是演算法實現:

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, left, right);//切分的基準值
    quickSort(array, left, partitionIndex-1);
    quickSort(array, partitionIndex+1, right);
  }
  return array;
}
function partition(array, left ,right) {   //分割槽操作
  for (var i = left+1, j = left; i <= right; i++) {//j是較小值儲存位置的遊標
    array[i] < array[left] && swap(i, ++j, array);//以第一個元素為基準
  }
  swap(left, j, 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, 直到無序區只有一個元素時停止.

如下是動圖效果:

JS中可能用得到的全部的排序演算法
桶排序示意圖

如下是演算法實現:

function heapAdjust(array, i, 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, largest, length);
  }
}
function heapSort(array) {
  //建立大頂堆
  length = array.length;
  for (var i = length>>1; i >= 0; i--) {
    heapAdjust(array, i, length);
  }
  //調換第一個與最後一個元素,重新調整為大頂堆
  for (var i = length - 1; i > 0; i--) {
    swap(0, i, array);
    heapAdjust(array, 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進行計數、累加計數, 其它同上.

如下是動圖效果:

JS中可能用得到的全部的排序演算法
計數排序

如下是演算法實現:

function countSort(array, keyName){
  var length = array.length,
      output = new Array(length),
      max,
      min,
      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, current, i, 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, 1, 1, 3, 2, 1, 4, 2],
    b = [
      {id: 2, s:'a'}, 
      {id: 1, s: 'b'}, 
      {id: 1, s: 'c'}, 
      {id: 3, s: 'd'}, 
      {id: 2, s: 'e'}, 
      {id: 1, s: 'f'}, 
      {id: 4, s: 'g'}, 
      {id: 2, s: 'h'}
    ];
countSort(a); // [1, 1, 1, 2, 2, 2, 3, 4]
countSort(b, 'id'); // [{id:1,s:'b'},{id:1,s:'c'},{id:1,s:'f'},{id:2,s:'a'},{id:2,s:'e'},{id:2,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的動圖效果:

JS中可能用得到的全部的排序演算法
基數排序
)

如下是演算法實現:

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代表關鍵字的個數.

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

未完待續...

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


本問就討論這麼多內容,大家有什麼問題或好的想法歡迎在下方參與留言和評論.

本文作者: louis

本文連結: louiszhai.github.io/2016/12/23/…

參考文章

相關文章