数据结构与算法的重温之旅(十一)——桶排序、基数排序和计数排序

      今天要讲的三个算法都有一个共同点,与以前讲的排序算法不一样,以前讲的算法都是基于比较的,而这里讲的排序算法都是基于非比较的,不涉及元素之间的相互比较。它们的算法时间复杂度是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) 了。


上一篇文章:数据结构与算法的重温之旅(十)——归并排序和快速排序​​​​​​​

相关文章
相关标签/搜索