「金三銀四」| 手撕排序演算法(JavaScript 實現)(上)

_沒有好名字了_發表於2019-03-11

前言

js

俗話說金三銀四 金九銀十,馬上又到了求職跳槽的黃金季。但是今年的這種大環境下,前端崗位的競爭勢必比往日更加激烈。

在如今的面試過程中,演算法是常常被考察的知識點,而排序作為演算法中比較基礎的部分,被面試官要求當場手寫幾種排序演算法也不算是過分的要求。

所以最近將十種常見的排序演算法整理如下,並附上一些常見的優化方法以及一些對應的leetcode(傳送門) 題目,建議大家可以申請個賬號刷起來,畢竟看明白了跟能夠寫出來並且通過LeetCode所有的 case 是兩碼事?,希望可以對剛接觸演算法以及最近需要參加面試的小夥伴有一點幫助。

畢竟手裡有糧 心裡不慌(逃~

  • 本篇將主要講解以下十個經典排序演算法:
    • 氣泡排序
    • 選擇排序
    • 插入排序
    • 歸併排序
    • 堆排序
    • 快速排序
    • 桶排序
    • 基數排序
    • 希爾排序
    • 計數排序

想看原始碼戳這裡,讀者可以 Clone 下來本地跑一下。BTW,文章配合原始碼體驗更棒哦~~~

最後,限於個人能力,如過在閱讀過程中遇到問題或有更好的優化方法,可以:

  • 提issue給我
  • 或是pull requests
  • 在本篇下評論

我都會看到並處理,歡迎Star,點贊,您的支援是我寫作最大的動力。

準備

  • 排序演算法的穩定性: 排序前後兩個相等的數相對位置不變,則演算法穩定。

  • 時間複雜度: 簡單的理解為一個演算法執行所耗費的時間,一般使用大O符號表示法,詳細解釋見時間複雜度

  • 空間複雜度: 執行完一個程式所需記憶體的大小。

常見演算法的複雜度(圖片來源於網路

複雜度
以下演算法最頻繁的操作就是交換陣列中兩個元素的位置(按照正序或者是逆序),簡單抽出一個函式如下:

/**
 * 按照正序比較並交換陣列中的兩項
 *
 * @param {Array} ary
 * @param {*} x
 * @param {*} y
 */
function swap(ary, x, y) {
  if (x === y) return
  var temp = ary[x]
  ary[x] = ary[y]
  ary[y] = temp
}
複製程式碼

氣泡排序(Bubble-Sort)

氣泡排序是一種簡單的排序演算法。它重複地走訪過要排序的數列,一次比較兩個元素,如果他們的順序錯誤就把他們交換過來。走訪數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。這個演算法的名字由來是因為越小的元素會經由交換慢慢“浮”到數列的頂端。

冒泡

演算法步驟: 假設我們最終需要的是依次遞增的有序陣列

  1. 從陣列的第一位開始,依次向後比較相鄰元素的大小,如果前一個比後一個小,那麼交換二者位置,直至陣列末尾。
  2. 下一輪比較的起始位置加1,然後重複第一步。
  3. 重複1~2,直至排序結束。
function bubbleSort1(ary) {
  var l = ary.length
  for (var i = 0; i < l-1; i++) {
    for (var j = 0; j <= l-2; j++) {
      if (ary[j] > ary[j + 1]) {
        swap(ary, j, j + 1)
      }
    }
  }
  return ary
}

複製程式碼

優化: 上述排序對於一個長度為 n 的陣列排序需要進行 n * n 次排序。(內外兩層迴圈次數都是 n ) 可以預見到的是,每進行一輪冒泡,從陣列末尾起有序部分長度就會加一,這就意味著陣列末尾的有序陣列進行比較的操作是無用的。

改進後的演算法如下:

function bubbleSort2(ary) {
  var l = ary.length
  for (var i = l - 1; i >= 0; i--) {
    // 優化的部分 arr[i]及之後的部分都是有序的
    for (var j = 0; j < i; j++) {
      if (ary[j] > ary[j + 1]) {
        swap(ary, j, j + 1)
      }
    }
  }
  return ary
}
複製程式碼

優化點:對於一些比較極限情況的處理,舉一個比較極限的例子,假如給定的陣列已經是有序陣列了,那麼 bubbleSort1 和 bubbleSort2 還是傻傻的去走完預定的次數 分別為 n*n 和 n!。 當然這種情況並不容易遇到,但是在排序的後段部分很容易遇到的是,理論上應該是未排序的部分其實已經是有序的了,我們需要對這種情況進行甄別並處理。 引入一個 swapedFlag ,如果在排序的上一步沒有進入內層迴圈,那麼表明剩餘元素都是有序的,排序完成。

優化後的程式碼如下:


/**
 * 氣泡排序 優化
 *
 * @param {Array} ary
 * @returns
 */
function bubbleSort3(ary) {
  var l = ary.length
  var swapedFlag
  for (var i = l - 1; i >= 0; i--) {
    swapedFlag = false
    for (var j = 0; j < i; j++) {
      if (ary[j] > ary[j + 1]) {
        swapedFlag = true
        swap(ary, j, j + 1)
      }
    }
    if (!swapedFlag) {
      break
    }
  }
  return ary
}
複製程式碼

選擇排序(Selection-Sort)

選擇排序是先在資料中找出最大或最小的元素,放到序列的起始;然後再從餘下的資料中繼續尋找最大或最小的元素,依次放到排序序列中,直到所有資料樣本排序完成。 複雜度分析:很顯然,選擇排序也是一個費時的排序演算法,無論什麼資料,都需要O(n*n) 的時間複雜度,不適宜大量資料的排序。

選擇

演算法步驟: 初始狀態為n的無序區(陣列)可經過n-1趟直接選擇排序得到有序結果

  1. 初始狀態:無序區為R[0..n],有序區為空;
  2. 第i趟排序(i=0,1,2,3…n-1)開始時,當前有序區和無序區分別為R[0..i]和R(i+1..n)。該趟排序從當前無序區中選出關鍵字最小的記錄的位置(下標 minPos),將 R[minPos] 與無序區的第1個記錄 R[i] 交換,所以有序區長度加 1 無序區長度減 1 。然後進行 i 加 1 並進行下一趟排序。
  3. n-1趟結束,陣列排序完成
function selectSort(ary) {
  var l = ary.length
  var minPos
  for (var i = 0; i < l - 1; i++) {
    minPos = i
    for (var j = i + 1; j < l; j++) {
      if (ary[j] - ary[minPos] < 0) {
        minPos = j
      }
    }
    swap(ary, i, minPos)
  }
  return ary
}
複製程式碼

插入排序(Insertion-Sort)

插入排序是先將待排序序列的第一個元素看做一個有序序列,把第二個元素到最後一個元素當成是未排序序列;然後從頭到尾依次掃描未排序序列,將掃描到的每個元素插入有序序列的適當位置,直到所有資料都完成排序;如果待插入的元素與有序序列中的某個元素相等,則將待插入元素插入到相等元素的後面。

插入
注:動圖對應的是最為原始的插入排序,沒有在網上找到二分法對應的動圖,大家見諒。

演算法步驟:

  1. 從第一個元素開始,該元素可以認為已經被排序
  2. 取出下一個元素,在已經排序的元素序列中從後向前掃描
  3. 如果該元素(已排序)大於新元素,將該元素移到下一位置
  4. 重複步驟 3,直到找到已排序的元素小於或者等於新元素的位置
  5. 將新元素插入到該位置後
  6. 重複步驟 2~5
function insertionSort1(arr) {
    var l = arr.length;
    var preIndex, current;
    for (var i = 1; i < l; i++) {
        preIndex = i - 1;
        current = arr[i];
        while (preIndex >= 0 && arr[preIndex] > current) {
            arr[preIndex + 1] = arr[preIndex];
            preIndex--;
        }
        arr[preIndex + 1] = current;
    }
    return arr;
}
複製程式碼

優化思路:

  • 二分法:即在將新增的數值插入到有序陣列中時,通過二分法減少查詢次數。
  • 連結串列:將有序陣列部分轉為連結串列結構,那麼插入的時間複雜度變為O(1),查詢複雜度變為O(n)(因為不方便使用二分法)
  • 排序二叉樹(BST): 將有序陣列部分轉化為排序二叉樹結構,然後中序遍歷該二叉樹。利用排序二叉樹可以兼顧插入方便以及查詢的效率。但是需要佔用額外空間。所以 BST 是比較平衡的一種思路, 也是空間換時間思路的體現。

簡單介紹下二分法: 二分查詢法,是一種在有序陣列中查詢某一特定元素的搜尋演算法。搜素過程從陣列的中間元素開始,如果中間元素正好是要查詢的元素,則搜素過程結束;如果某一特定元素大於或者小於中間元素,則在陣列大於或小於中間元素的那一半中查詢,而且跟開始一樣從中間元素開始比較。如果在某一步驟陣列為空,則代表找不到。這種搜尋演算法每一次比較都使搜尋範圍縮小一半。

注: 準備面試的同學能夠理解並記憶以下一種即可,排序二叉樹和連結串列的實現限於篇幅就不細說,準備以後寫資料結構時再詳細介紹,本篇介紹下使用二分法優化拆入排序的思路:

/**
 * 插入排序
 *
 * @param {*} ary
 * @returns {Arrray} 排序完成的陣列
 */
function insertSort2(ary) {
  return ary.reduce(insert, [])
}

/**
 * 使用二分法完成查詢插值位置,並完成插值操作。
 * 時間複雜度 logN
 * @param {*} sortAry 有序陣列部分
 * @param {*} val
 * @returns
 */
function insert(sortAry, val) {
  var l = sortAry.length
  if (l == 0) {
    sortAry.push(val)
    return sortAry
  }

  var i = 0,
    j = l,
    mid
  //先判斷是否為極端值
  if (val < sortAry[i]) {
    return sortAry.unshift(val), sortAry
  }
  if (val >= sortAry[l - 1]) {
    return sortAry.push(val), sortAry
  }

  while (i < j) {
    mid = ((j + i) / 2) | 0
    //結束條件 等價於j - i ==1
    if (i == mid) {
      break
    }
    if (val < sortAry[mid]) {
      j = mid
    }
    if (val == sortAry[mid]) {
      i = mid
      break
    }
    //結束條件 統一c處理對外輸出i
    if (val > sortAry[mid]) {
      i = mid
    }
  }
  var midArray = [val]
  var lastArray = sortAry.slice(i + 1)
  sortAry = sortAry
    .slice(0, i + 1)
    .concat(midArray)
    .concat(lastArray)
  return sortAry
}
複製程式碼

歸併排序(Merge-Sort)

歸併排序是利用歸併的思想實現的排序方法,該演算法採用經典的分治(divide-and-conquer)策略(分治法將問題分(divide)成一些小的問題然後遞迴求解,而治(conquer)的階段則將分的階段得到的各答案"修補"在一起,即分而治之)。

穩定性分析:歸併排序嚴格遵循從左到右或從右到左的順序合併子資料序列, 它不會改變相同資料之間的相對順序, 因此歸併排序是一種穩定的排序演算法.

歸併排序

演算法步驟:

  1. 把長度為n的輸入序列分成兩個長度為n/2的子序列;
  2. 對這兩個子序列分別採用歸併排序;
  3. 將兩個排序好的子序列合併成一個最終的排序序列。
// 採用自上而下的遞迴方法
function mergeSort(ary) {
  if (ary.length < 2) {
    return ary.slice()
  }

  var mid = Math.floor(ary.length / 2)
  var left = mergeSort(ary.slice(0, mid))
  var right = mergeSort(ary.slice(mid))
  var result = []

  while (left.length && right.length) {
    if (left[0] <= right[0]) {
      result.push(left.shift())
    } else {
      result.push(right.shift())
    }
  }

  result.push(...left, ...right)

  return result
}
複製程式碼

堆排序(Heapsort)

堆排序是指利用堆這種資料結構所設計的一種排序演算法。堆積結構具有如下特點:即子結點的鍵值總是小於(或者大於)它的父節點,據此可分為以下兩類:

  • 最大堆:每個節點的值都大於或等於其子節點的值,在堆排序演算法中用於升序排列;
  • 最小堆:每個節點的值都小於或等於其子節點的值,在堆排序演算法中用於降序排列;

堆排序

演算法步驟:

  1. 建立一個堆 H[0……n-1];
  2. 把堆首(最大值)和堆尾互換;
  3. 把堆的尺寸縮小 1,並呼叫 reheap 方法重新聚堆,目的是把新的陣列頂端資料調整到相應位置,重新變為最大堆。
/**
 * 聚堆:將陣列中的某一項作為堆頂,調整為最大堆。
 * 把在堆頂位置的一個可能不是堆,但左右子樹都是堆的樹調整成堆。
 *
 * @param {*} ary 待排序陣列
 * @param {*} topIndex 當前處理的堆的堆頂
 * @param {*} [endIndex=ary.length - 1] 陣列的末尾邊界
 */
function reheap(ary, topIndex, endIndex = ary.length - 1) {
  if (topIndex > endIndex) {
    return
  }

  var largestIndex = topIndex
  var leftIndex = topIndex * 2 + 1
  var rightIndex = topIndex * 2 + 2

  if (leftIndex <= endIndex && ary[leftIndex] > ary[largestIndex]) {
    largestIndex = leftIndex
  }
  if (rightIndex <= endIndex && ary[rightIndex] > ary[largestIndex]) {
    largestIndex = rightIndex
  }

  if (largestIndex != topIndex) {
    swap(ary, largestIndex, topIndex)
    reheap(ary, largestIndex, endIndex)
  }
}

/**
 * 將陣列調整為最大堆結構
 *
 * @param {*} ary
 * @returns
 */
function heapify(ary) {
  for (var i = ary.length - 1; i >= 0; i--) {
    reheap(ary, i)
  }
  return ary
}

/**
 * 堆排序
 *
 * @param {*} ary
 * @returns
 */
function heapSort(ary) {
  heapify(ary)
  for (var i = ary.length - 1; i >= 1; i--) {
    swap(ary, 0, i)
    reheap(ary, 0, i - 1)
  }
  return ary
}
複製程式碼

快速排序(Quicksort)

快速排序使用分治法策略來把一個陣列分為兩個子陣列。首先從陣列中挑出一個元素,並將這個元素稱為「基準」,英文pivot。重新排序陣列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任何一邊)。在這個分割槽結束之後,該基準就處於陣列的中間位置。這個稱為分割槽(partition)操作。之後,在子序列中繼續重複這個方法,直到最後整個資料序列排序完成。

快排

注意: 在 js 中實現快排中最耗費時間的就是交換,本例子中哨兵的元素是隨機取得的,而上面動圖中總是的取陣列中的第一個值作為哨兵(pivot),那麼考慮一種極限情況,在 [9,8,7,6,5,4,3,2,1] 重中例子中使用就地排序就演算法複雜度就會變成 n*n。 本例中的哨兵是從陣列中隨機抽取的,個人認為比取首元素的方案更優。

應用: 取前K大元素、求中位數 、leetcode

嗯,先整一個粗暴版本稍微瞭解下快排的基本思路:

//快排粗暴版本
function quickSort1(ary) {
  if (ary.length < 2) {
    return ary.slice()
  }
  var pivot = ary[Math.floor(Math.random() * ary.length)]
  var left = []
  var middle = []
  var right = []
  for (var i = 0; i < ary.length; i++) {
    var val = ary[i]
    if (val < pivot) {
      left.push(val)
    }
    if (val === pivot) {
      middle.push(val)
    }
    if (val > pivot) {
      right.push(val)
    }
  }

  return quickSort1(left).concat(middle, quickSort(right))
}

複製程式碼

這個是推薦掌握的,很重要(敲黑板)

演算法步驟

  1. 從數列中挑出一個元素,稱為 "哨兵"(pivot);
  2. 重新排序數列,所有元素比哨兵值小的擺放在哨兵前面,所有元素比哨兵值大的擺在哨兵的後面(相同的數可以到任一邊)。在這個分割槽退出之後,該哨兵就處於數列的中間位置。這個稱為分割槽(partition)操作;
  3. 遞迴地把小於哨兵值元素的子數列和大於哨兵值元素的子數列排序。
function quickSort2(ary, comparator = (a, b) => a - b) {
  return partition(ary, comparator)
}
function partition(ary, comparator, start = 0, end = ary.length - 1, ) {
  if (start >= end) {
    return
  }

  var pivotIndex = Math.floor(Math.random() * (end - start + 1) + start)
  var pivot = ary[pivotIndex]

  swap(ary, pivotIndex, end)

  for (var i = start - 1, j = start; j < end; j++) {
    if (comparator(ary[j], pivot) < 0) {
      i++
      swap(ary, i, j)
    }
  }

  swap(ary, i + 1, end)
  partition(ary, comparator, start, i)
  partition(ary, comparator, i + 2, end)
  return ary
}
複製程式碼

未完待續~

參考:

  1. javascript-algorithms
  2. 十大經典排序演算法(動圖演示)

「前端也要學點演算法」&& 「前端進階指北」

2019 年度準備持續更新「前端也要學點演算法」和 「前端進階指北」兩個系列,本篇是演算法系列的第一篇 ,演算法系列計劃旨在梳理學習常見的資料結構和演算法知識,進階指北系列旨在循序漸進逐個攻克前端進階重難點,並且能夠分享出來對前端同學們有一些幫助。 希望大家在閱讀的過程當中可以斧正文中出現不嚴謹或是錯誤的地方,本人將不勝感激。

  • 內容: 常見的資料結構和演算法、前端進階、面試必備知識。
  • 目的: 這個系列的文章可以讓讀者有一些收穫。

希望各位可以多多探討,不吝賜教。 最近打算建個前端學習小分隊,一起討論前端相關的知識,共同進步。感興趣的可以以加我微信(微信idgk15510618107)。新增時請註明-掘金,回覆加群即可。

「金三銀四」| 手撕排序演算法(JavaScript 實現)(上)

相關文章