常見排序演算法原理及JS程式碼實現

Hewitt發表於2020-08-07

建立時間:2020-08-07

本文只是將作者學習的過程以及演算法理解進行簡單的分享,提供多一個角度的理解說明,或許讓你的困惑能得以解決(程式碼或說明若有問題,歡迎留言、聯絡更正!以免造成更多困惑

如果要更深入研究這些演算法的同學,社群中同型別更優秀,單個演算法更深入剖析的文章也是比比皆是,這裡或許作為一個常見排序演算法入門學習瞭解更準確


排序名稱 最快時間 最慢時間 空間複雜度
氣泡排序 O(n) O(n^2) O(1)
選擇排序 O(n^2) O(n^2) O(1)
插入排序 O(n) O(n^2) O(1)
希爾排序 O(n*log2n) O(n^2) O(1)
歸併排序 O(nlogn) O(n) O(n)
堆排序 O(nlogn) O(nlogn) O(1)
快速排序 O(nlogn) O(n^2) O(1),O(n)

以上時間和空間複雜度會根據演算法的優化有所不同


生成測試所用,包含隨機十萬條資料的陣列

const arr = []
for (let i = 0; i < 100000; i++) {
    arr.push(Math.random())
}

以下標註的時間均為對該隨機陣列的資料排序的時間,這裡的時間只是作為一個參考,因為並沒有控制到只有唯一變數(每個排序演算法用到的陣列長度相同,但陣列值不同),
所以這裡的時間只反應常規情況

執行時間的計算使用 console.time()

陣列 sort() 方法

實現也是基於快排做了很多的優化演算法,以保障各種情況都能穩定較快的實現排序 檢視C++實現原始碼

時間:≈ 75ms

function sortCompare(array) {
    array.sort((a, b) => (a-b))
}

氣泡排序

原理:依次比較兩個相鄰的元素,將較大的放到右邊(升序排列)

一輪迴圈只找到一個最值,然後通過多次這樣的迴圈(所以有兩層巢狀迴圈),獲得一個排序結果

以下是經過簡單優化的演算法實現:

時間:≈ 21899ms

function bubbling(array) {
    const len = array.length
    let sorted = true
    /* 每找到一個最值,需要一次迴圈 */
    for (let i = 0; i < len; i++) {
        /* 必須每輪迴圈前,假設是排好序後的陣列,防止只需要前幾次迴圈就排好的情況 */
        sorted = true
        /* 這裡的迴圈是找出當前輪的最值 */
        /* len-1-i 保障 j+1 能取到,同時放到最後的數,不用參與下一輪的迴圈,因為它已經是上一輪找出的最值 */
        for (let j = 0; j < len - 1 - i; j++) {
            if (array[j] > array[j + 1]) {
                let temp = array[j]
                array[j] = array[j + 1]
                array[j + 1] = temp
                sorted = false
            }
        }
        /* 如果是已經排好序了就直接退出迴圈,此時最優時間複雜度 O(n) */
        if (sorted) break
    }

    return array
}

選擇排序

原理:從剩餘未排序序列中找到最小(大)元素,放置在已排序序列的末尾位置,以此迴圈,直到所有元素均排序完畢

時間:≈ 6353ms

function selectionSort(array) {
    let len = array.length
    for (let i = 0; i < len; i++) {
        /* 預設開始的第一個值的位置放置下一個最小值 */
        let minIndex = i
        /* 查詢剩餘數值中的最小值,從 i + 1 開始的目的是避免與自身進行一次比較 */
        for (let j = i + 1; j < len; j++) {
            if(array[minIndex] > array[j]) {
                minIndex = j
            }
        }
        /* 將最小值和當前位置(i)的值進行交換 */
        let temp = array[minIndex]
        array[minIndex] = array[i]
        array[i] = temp
    }
    return array
}

插入排序

原理: 將未排序佇列中的數值,逐個與已排序佇列中的數進行比較,當出現大於或小於已排序佇列中的某個數時,進行插入操作

注意與選擇排序的區別,選擇排序是在未排序的數中找最值,然後交換位置,插入排序則是在已排序的的數中找對應的第一個相對最值

時間:≈ 2416ms

function insertionSort(array) {
    let len = array.length
    for (let i = 1; i < len; i++) {
        /* 記錄當前未排序的數,該數將會和有序數列中的數進行比較 */
        let current = array[i]
        /* 有序數列的最後一個數(如果是從小到大排列,也就是最大的數) */
        let endIndex = i - 1 
        while (endIndex >=0 && array[endIndex] > current) {
            /* 將有序數列中的數,逐一與當前未排序數進行比較直到,找出比當前未排序數小的數即停止 */
            array[endIndex + 1] = array[endIndex]
            endIndex--
        }
        /* 將最後一個往後移動空出來的位置賦值為,當前未排序數 */
        array[endIndex+1] = current
    }
    return array
}

希爾排序

原理:

插入排序的一種優化

  1. 設定一個增量,將陣列中的數按此增量進行分組(比如增量為4,那下標為0,4,8...的數為一組)
  2. 對分組的數進行插入排序
  3. 縮小增量
  4. 重複步驟1、2、3,直到增量為1
  5. 當增量為1時,對整個陣列進行一次插入排序,輸出最後結果

時間複雜度與增量選取有關,以下演算法時間複雜度為 O(n^(3/2))

非穩定排序(2個相等的數,在排序完成後,原來在前面的數還是在前面,即為穩定排序)

時間:≈ 35ms

function shellSort(array) {
    let len = array.length, gap = 1;
    /* 此處獲取一個最大增量,增量的獲取方法不固定,這裡採用比較常見的方式,一定要保證最後能取到1 */
    while(gap < len/3) {
        gap = gap*3+1;
    }

    /* 每更新一次增量就進行一次插入排序 */
    while(gap>0) {
        /* 以下邏輯與插入排序一致,當增量變為1時即完全一致 */
        for (let i = gap; i < len; i++) {
            /* 這裡要迴圈到陣列最後是因為要保障當前分組中的每一個數都經過排序,所以當前分組靠前的資料會被與後面的資料進行多次排序 */
            let current = array[i]
            let endIndex = i - gap
            while(endIndex>=0 && array[endIndex] > current) {
                array[endIndex + gap] = array[endIndex]
                endIndex -= gap
            }
            array[endIndex+gap] = current
        }
        gap = Math.floor(gap/3)
    }
    return array
}

分治法:把一個複雜的問題分成兩個或更多的相同或相似的子問題,再把子問題分成更小的子問題……直到最後子問題可以簡單的直接求解,原問題的解即子問題的解的合併

歸併排序

原理:將當前陣列,遞迴分組,比較大小後再一 一合併分組,是採用分治法的一個應用

  1. 獲取一箇中間位置的值,然後以該位置為中心點分組
  2. 遞迴進行分組
  3. 比較當前兩個分組,將其合併為一個陣列

時間:≈ 1170ms

function mergeSort(array) {
    const len = array.length
    if(len<2) return array
    const middle = Math.floor(len/2)
    
    /* 取中間值進行分組 */
    const left = array.slice(0, middle)
    const right = array.slice(middle)

    /* 遞迴分組 */
    return merge(mergeSort(left), mergeSort(right))
}

function merge(left, right) {
    const result = []
    /* 兩個分組都有值時,逐個進行比較 */
    while (left.length && right.length) {
        if(left[0] <= right[0]) {
            result.push(left.shift())
        } else {
            result.push(right.shift())
        }
    }

    /* 只有一個分組時,表明其全部為最大值,直接全部放入結果陣列即可 */
    if(left.length){
        result.push(...left)
    }

    if(right.length){
        result.push(...right)
    }
    return result
}

堆排序

分為大頂堆(子節點都小於父節點),小頂堆(子節點都大於父節點)

原理:

  1. 根據給定的資料建立堆
  2. 將堆頂和堆尾互換,將堆長度減1
  3. 遞迴步驟1、2

時間:≈ 46ms

function heapSort(array) {
    let length = array.length
    /* 第一個非葉子節點(葉子節點:沒有子節點的節點): n/2 -1 */
    /* 為什麼從這個點開始,也是因為這是最後一個擁有子節點的父節點,其可能會發生父子節點交換 */
    const node =  Math.floor(length/2) - 1

    /* 第一步先將陣列構建為堆 這裡是大頂堆 */
    for (let i = node; i >= 0 ; i--) {
        maxHeap(array, i, length)
    }

    /* 第二步 將堆頂元素與堆尾元素交換 再將前 (n-1) 個數重複構建堆 */
    for (let j = length - 1; j > 0; j--) {
        swap(array, 0, j)
        length--
        /* 這裡相當於把第一個葉子節點改變了,所以下面從 0 開始, 當前堆的堆尾前一個數為結束 重新構建堆 */
        maxHeap(array, 0, length)
    }

    return array
}

function maxHeap(array, i, length) {
    /* 左子節點 */
    let left = i*2 + 1
    /* 右子節點 */
    let right = i*2 + 2
    /* 父節點 */
    let parent = i

    /* 找出子節點中比父節點大的數進行交換 */
    if(left < length && array[left] > array[parent]) {
        parent = left
    }

    /* 這裡兩個條件都觸發也沒有關係,只要保障,一個比父節點大的子節點被移上去即可 */
    if(right < length && array[right] > array[parent]) {
        parent = right
    }

    if(parent !== i) {
        swap(array,i, parent)
        /* 表示有資料移動,所以要重排一下資料移動後,所影響到的父子節點,也就是此時的 parent 節點和其子節點 */
        maxHeap(array, parent, length)
    } 
}

function swap(arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

快速排序

原理:

  1. 在陣列中找一個基準
  2. 陣列中的數與該基準相比較,比它小的放在其前面,比它大的放在其後面(分割槽操作)
  3. 再遞迴的去操作基準前、後的分割槽
  • 方式一:
    需要 O(n) 的額外儲存空間,和歸併排序一樣

但是程式碼更清晰的體現快排的思想

時間:≈ 77ms

function quickSort (array) {
    if (array.length < 2) return array;
    const pivot = array[0];
    let left = []
    let right = []
    for (let i = 1, length = array.length; i < length; i++) {
        if(array[i] < pivot) {
            left.push(array[i])
        } else {
            right.push(array[i])
        }
    }
    return quickSort(left).concat([pivot], quickSort(right));
}
  • 方式二:

原地排序

時間: ≈ 34ms

function quickSort(array, left, right) {
    if(left<right) {
        pivotIndex = fill(array, left, right)
        quickSort(array, left, pivotIndex-1)
        quickSort(array, pivotIndex+1, right)
    }
    return array
}

function fill(array, left, right) {
    const pivotValue = array[left]
    while(left < right){
        /* 右邊大於基準的資料不需要移動位置 */
        /* 這裡或下面的迴圈,一定要確保有一處把相等的情況包含在內 */
        while(array[right] >= pivotValue && left < right){
            right--
        }
        /* 將右邊第一個掃描到的小於基準的資料移動到左邊的空位 */
        array[left] = array[right]

        /* 左邊小於基準的資料不需要移動位置 */
        while(array[left] < pivotValue && left < right){
            left++
        }
        array[right] = array[left]
    }

    /* 這裡right和left 相等了 */
    array[right] = pivotValue

    return right
}

還有一些更好的優化,比如基準數的選取,避免最壞時間複雜度情況的發生,可自行探索


總結:

在實際專案中可能直接用到這些演算法就能解決掉業務需求的情況並不多,甚至直接用 Array.sort() 也能解決。

但是業務需求千變萬化,多種多樣,總有需要你從底層去更改、優化、變異演算法的情況,此時就需要用你理解的這些基本演算法的原理來快速解決業務問題。

最後祝大家資料結構某幾個章節複習順利!

歡迎交流 Github

相關文章