數據結構與算法的重溫之旅(十一)——桶排序、基數排序和計數排序

      今天要講的三個算法都有一個共同點,與以前講的排序算法不一樣,以前講的算法都是基於比較的,而這裏講的排序算法都是基於非比較的,不涉及元素之間的相互比較。它們的算法時間複雜度是O(n),因爲這三個排序算法的時間複雜度都是線性,因此也稱爲線性排序。下面來說講這三個算法的思想和實現。git

1、桶排序(Bucket sort)

桶排序,顧名思義,核心思想是將要排序的數據分到幾個有序的桶裏,每一個桶裏的數據再單獨進行排序。桶內排完序以後,再把每一個桶裏的數據按照順序依次取出,組成的序列就是有序的了。算法

那時間複雜度如何分析呢。假設咱們有n個數據,咱們要把數據分到m個桶內,若是數據是均勻分佈的,則每一個桶裏就有k=n/m個元素。每一個桶內內部使用快速排序,時間複雜度爲O(k*logk)。這樣子m個桶則時間複雜度是O(m*k*logk),由於k=n/m,因此整個桶排序的時間複雜度是O(n*log(n/m))。若是當桶的個數接近n時,那log(n/m)就是一個很小的常量了,這個時候桶排序的時間複雜度則是接近O(n)。上篇文章咱們分析過快速排序因爲不須要藉助額外的數組來存儲數據,因此這裏的桶排序的空間複雜度是O(m)。算是犧牲了必定的空間來提升時間上的性能。數組

桶排序看似很好,其實實現的前提比較苛刻。首先,要排序的數據須要很容易就能劃分紅 m 個桶,而且,桶與桶之間有着自然的大小順序。這樣每一個桶內的數據都排序完以後,桶與桶之間的數據不須要再進行排序。其次,數據在各個桶之間的分佈是比較均勻的。若是數據通過桶的劃分以後,有些桶裏的數據很是多,有些很是少,很不平均,那桶內數據排序的時間複雜度就不是常量級了。在極端狀況下,若是數據都被劃分到一個桶裏,那就退化爲 O(nlogn) 的排序算法了。bash

其實桶排序比較適合用在內存不足,數據量又很大,沒法講數據所有加載到內存中的外部排序中。好比說咱們有 10GB 的訂單數據,咱們但願按訂單金額(假設金額都是正整數)進行排序,可是咱們的內存有限,只有幾百 MB,沒辦法一次性把 10GB 的數據都加載到內存中。這個時候該怎麼辦呢?數據結構

咱們能夠先掃描一遍文件,看訂單金額所處的數據範圍。假設通過掃描以後咱們獲得,訂單金額最小是 1 元,最大是 10 萬元。咱們將全部訂單根據金額劃分到 100 個桶裏,第一個桶咱們存儲金額在 1 元到 1000 元以內的訂單,第二桶存儲金額在 1001 元到 2000 元以內的訂單,以此類推。每個桶對應一個文件,而且按照金額範圍的大小順序編號命名(00,01,02…99)。理想的狀況下,若是訂單金額在 1 到 10 萬之間均勻分佈,那訂單會被均勻劃分到 100 個文件中,每一個小文件中存儲大約 100MB 的訂單數據,咱們就能夠將這 100 個小文件依次放到內存中,用快排來排序。等全部文件都排好序以後,咱們只須要按照文件編號,從小到大依次讀取每一個小文件中的訂單數據,並將其寫入到一個文件中,那這個文件中存儲的就是按照金額從小到大排序的訂單數據了。app

不過,你可能也發現了,訂單按照金額在 1 元到 10 萬元之間並不必定是均勻分佈的 ,因此 10GB 訂單數據是沒法均勻地被劃分到 100 個文件中的。有可能某個金額區間的數據特別多,劃分以後對應的文件就會很大,無法一次性讀入內存。這又該怎麼辦呢?針對這些劃分以後仍是比較大的文件,咱們能夠繼續劃分,好比,訂單金額在 1 元到 1000 元之間的比較多,咱們就將這個區間繼續劃分爲 10 個小區間,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。若是劃分以後,101 元到 200 元之間的訂單仍是太多,沒法一次性讀入內存,那就繼續再劃分,直到全部的文件都能讀入內存爲止。dom

下面的桶排序代碼利用快速排序來對每一個桶進行排序,排序以後進行數組合並。假設咱們這裏的數據都是十分均勻的,其實桶排序算法的核心就是對桶的分類,由於在現實中數據不必定十分的均勻,下面給出的代碼是基於數據是均勻的:post

/**
 * @param {array} array 要排序的數組
 * @param {number} min 最小值
 * @param {number} max 最大值
 * @param {number} bucketCapacity 每一個桶平均的長度
 * @description 桶排序算法
 * */
function bucketSort(array, min, max, bucketCapacity) {
    let bucketCount = Math.floor((max - min + bucketCapacity) / bucketCapacity);
    let buckets = new Array(bucketCount);

    for (let i = 0; i < bucketCount; ++i) {
        buckets[i] = [];
    }

    for (let i in array) {
        if (array.hasOwnProperty(i)) {
            let n = array[i];
            let k = Math.floor((n - min) / bucketCapacity);

            buckets[k].push(n);
        }
    }

    let p = 0;
    for (let i in buckets) {
        if (buckets.hasOwnProperty(i)) {
            quickSort(buckets[i], 0, buckets[i].length - 1);

            for (let j in buckets[i]) {
                if (buckets[i].hasOwnProperty(j)) {
                    array[p++] = buckets[i][j];
                }
            }
        }
    }
}

/**
 * @param {Array} arr 要排序的數組
 * @param {number} start 當前數組的第一個下標
 * @param {number} end 當前數組的最後一個下標
 * @description 快速排序的遞歸方法
 * */
function quickSort(arr, start, end) {
    if (start >= end) return false;
    let pivot = partition(arr, start, end);
    quickSort(arr, start, pivot - 1);
    quickSort(arr, pivot + 1, end)
}

/**
 * @param {Array} arr 要合併的數組
 * @param {number} start 當前數組第一個下標
 * @param {number} end 當前數組的最後一個下標
 * @description 快速排序合併方法
 * */
function partition(arr, start, end) {
    let pivot = arr[end];
    let i = start;
    for (let j = start; j <= end - 1; j++) {
        if (arr[j] < pivot) {
            let temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
            i = i + 1
        }
    }
    let temp = arr[i];
    arr[i] = arr[end];
    arr[end] = temp;
    return i
}複製代碼

在這個算法裏,簡單的用除以10來對桶進行分類。這裏分紅了九個桶,區間是[0, 10], [11, 20], [21, 30], [31, 40], [41, 50], [51, 60], [61, 70], [71, 80], [81, 90]這九個區間。而後把符合區間的數存入各自對應的桶中,最後面利用快速排序對桶內的元素進行排序,以後合併。性能

針對若是某個區間的數據不少的狀況,好比上面的例子里加入咱們傳入的數據的區間都是在0到10之間,這就致使了其餘數據空間被白白的浪費了存儲空間。其實咱們能夠按照上面的說法,對0到10的之間再進行一次細分,更加充分的利用空間來換時間。ui

2、計數排序(Counting sort)

計數排序則是上面桶排序的簡單粗暴版。當要排序的數組的最大值或者範圍不是很大的時候,咱們能夠拿最大值k或者數組長度k來造成k個數組,每一個數組存一個元素或者存相同的元素,以後進行合併。好比整年級有3000多人,某學科考試總分是100分,要對整年級全部學生進行快速排序求得排名。這個時候能夠用101個數組來存儲0到100分區間裏的學生,每一個數組裏的學生分數都是同樣,以後合併數組便可得出排名。咱們這裏改一下上面桶排序的代碼,獲得的代碼以下:

/**
 * @param {array} array 要排序的數組
 * @param {number} min 最小值
 * @param {number} max 最大值
 * @description 計數排序算法
 * */
function bucketSort(array, min, max) {
    let bucketCount = Math.round(max - min) + 1;
    let buckets = new Array(bucketCount);

    for (let i = 0; i < bucketCount; ++i) {
        buckets[i] = [];
    }
    for (let i in array) {
        if (array.hasOwnProperty(i)) {
            let n = array[i];
            let k = Math.floor((n - min));
            buckets[k].push(n);
        }
    }

    let p = 0;
    for (let i in buckets) {
        if (buckets.hasOwnProperty(i)) {
            for (let j in buckets[i]) {
                if (buckets[i].hasOwnProperty(j)) {
                    array[p++] = buckets[i][j];
                }
            }
        }
    }
}

let arr = []
for (let i = 0; i < 100; i++) {
    arr[i] = Math.round(Math.random()*10)
}
let maxNum = Math.max.apply(null, arr);
let minNum = Math.min.apply(null, arr);

bucketSort(arr, minNum, maxNum);複製代碼

除了直接用桶排序的思路外,咱們能夠利用計數排序自己的定義來實現。下面我經過詳細的說明爲你們解答一下計數排序爲何有「計數」兩個字。

好比咱們有一組數據是這樣的:2,5,3,0,2,3,0,3。這八個元素最大是5,最小是0,這個時候咱們則能夠像上面講的同樣,用一個長度爲最大數減最小數的數組temp,該temp數組的下標就是這些數據的值,temp裏面的值存的是原有數據出現的頻率,這樣咱們就能夠獲得一個temp的數組是這樣的:[2,0,2,3,0,1]。拿到了這個頻率數組temp以後咱們再新建一個長度和頻率數組temp同樣長度的累加數組tempSum,這個數組的元素的值是當前項累加前面全部項,因而咱們獲得的這個tempSum數組是這樣的:[2,2,4,7,7,8]。這個時候咱們拿到這個tempSum數組就能排上用場了,咱們先新建一個與原始數據相同長度的數組finishArr,這時開始遍歷原始數據,從第一個元素2開始,以這個2的值做爲tempSum的下標去找tempSum[2]的值,這個值爲4表示的是大於等於2的元素有4個,這個時候咱們就把tempSum[2]這個值看成finishArr數組的第幾個元素,tempSum[2]的下標則是這個元素的值,這個時候能夠獲得finishArr[3] = 2,存入一個值後原來的tempSum[2]這個值就要減一,表示已經存放了一個元素,如今小於等於2的元素就只有3個,而後如此類推就能夠獲得排好的數組。代碼以下:

let arr = [2, 5, 3, 0, 2, 3, 0, 3];
let maxNum = Math.max.apply(null, arr);
let minNum = Math.min.apply(null, arr);

function areaArr(min, max) {
    let length = max - min;
    let tempArr = new Array(length);
    for (let i = 0; i <= length; i++) {
        tempArr[i] = 0
    }
    return tempArr
}

function countingSort(arr, cb) {
    let tempArr = cb(minNum, maxNum);
    for (let i = 0; i < arr.length; i++) {
        tempArr[arr[i]]++
    }

    for (let i = 1; i < tempArr.length; i++) {
        tempArr[i] = tempArr[i - 1] + tempArr[i]
    }

    let finishArr = new Array(arr.length);
    for (let i = 0; i < arr.length; i++) {
        let index = tempArr[arr[i]] - 1;
        finishArr[index] = arr[i];
        tempArr[arr[i]]--
    }

    for (let i = 0; i < arr.length; i++) {
        arr[i] = finishArr[i]
    }
}

countingSort(arr, areaArr);複製代碼

不過計數排序只能用在數據範圍不大的狀況,若是數據範圍 k 比要排序的數據 n 大不少,就不適合用計數排序了。並且計數排序只能給非負整數排序,若是要排序的數據是其餘類型的,要講其在不改變相對大小的狀況下,轉化爲非負整數。

3、基數排序(Radix sort)

基數排序的實現思想其實和桶排序差很少,基於算法的穩定性考慮,如給定的一組數據[123,321,234,543,456,765,980],咱們能夠對末尾進行排序,好比這裏對個位數的數從小到大進行排序,可得[980,321,123,543,234,765,456],而後再對十位數上的數從小到大排序,可得[321,123,234,543,456,765,980],最後對百位數上進行從小到大排序可得[123,234,321,456,543,765,980]。這樣便可獲得結果,代碼以下:

let arr = [321,123,234,543,456,765,678,978,890];
function radixSort(val) {
    let arr = val.slice(0);
    const max = Math.max(...arr);
    let digit = `${max}`.length;
    let start = 1;
    let buckets = [];
    while(digit > 0) {
        start *= 10;
        for(let i = 0; i < arr.length; i++) {
            const index = arr[i] % start;
            if (!buckets[index]) {
                (buckets[index] = [])
            }
            buckets[index].push(arr[i])
        }
        arr = [];
        for(let i = 0; i < buckets.length; i++) {
            if (buckets[i]) {
                (arr = arr.concat(buckets[i]))
            }
        }
        buckets = [];
        digit --
    }
    return arr
}複製代碼

在這裏基數排序的時間複雜度是O(k*n),這個k取決於最大元素的位數,當k的大小接近於n時,這個算法就退化成O(n*n)。該算法是穩定排序算法,基數排序對要排序的數據是有要求的,須要能夠分割出獨立的「位」來比較,並且位之間有遞進的關係,若是 a 數據的高位比 b 數據大,那剩下的低位就不用比較了。除此以外,每一位的數據範圍不能太大,要能夠用線性排序算法來排序,不然,基數排序的時間複雜度就沒法作到 O(n) 了。


上一篇文章:數據結構與算法的重溫之旅(十)——歸併排序和快速排序​​​​​​​

相關文章
相關標籤/搜索