阅读 69

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

      今天要讲的三个算法都有一个共同点,与之前讲的排序算法不同,之前讲的算法都是基于比较的,而这里讲的排序算法都是基于非比较的,不涉及元素之间的相互比较。它们的算法时间复杂度是O(n),由于这三个排序算法的时间复杂度都是线性,所以也称为线性排序。下面来讲讲这三个算法的思想和实现。

一、桶排序(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) 的排序算法了。

其实桶排序比较适合用在内存不足,数据量又很大,无法讲数据全部加载到内存中的外部排序中。比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?

我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。

不过,你可能也发现了,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。

下面的桶排序代码利用快速排序来对每个桶进行排序,排序之后进行数组合并。假设我们这里的数据都是十分均匀的,其实桶排序算法的核心就是对桶的分类,因为在现实中数据不一定十分的均匀,下面给出的代码是基于数据是均匀的:

/**
 * @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的之间再进行一次细分,更加充分的利用空间来换时间。

二、计数排序(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 大很多,就不适合用计数排序了。而且计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要讲其在不改变相对大小的情况下,转化为非负整数。

三、基数排序(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) 了。


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




关注下面的标签,发现更多相似文章
评论