数据结构与算法——计数排序、桶排序、基数排序

数据结构与算法——计数排序、桶排序、基数排序

计数排序

计数排序有如下四个步骤。

  • 首先会对每个输入进行频率统计,得到元素的频率表;
  • 然后将频率表转换为该元素的开始索引
  • 根据各个元素的开始索引,将相同元素分类到临时数组中。
  • 最后将临时数组中的元素写回到原数组中。

上面介绍了计数排序的流程,举个例子,要对[9, 7, 6, 3, 9, 2, 7, 3, 2, 8]排序。首先建立频率表,为了尽量节省空间,需要找到待排序数组中最大元素和最小元素,分别是2和9,该区间共有9 - 2 + 1 = 8种可能的值。为此建立一个长度为8 +1的数组,为什么多加1,后面会说到。

于是通过将待排序的元素计数,得到如下频率表。

元素 频率
2 2
3 2
4 0
5 0
6 1
7 2
8 1
9 2

每个元素减去最小值再加1作为索引,使用一个coun[]表示上表。比如元素7,减去最小值2等于5,应该存放在count[6]中,按照此规则count[] = {0, 2, 2, 0, 0, 1, 2, 1, 2}长度为9,且count[0]始终为0,除开count[0]外后面的数字依次表示了2~9的出现频率。如果想要知道元素7出现了几次,和存入时一样,将元素减去最小值再加1作为索引,于是count[7 - min+1] = count[6] = 2,7确实是出现了两次的。

接下来由上表得到每个元素的开始索引。

元素 开始索引
2 0
3 2
4 4 (实际不存在这个元素)
5 4 (实际不存在这个元素)
6 4
7 5
8 7
9 8

对于不存在的元素,使用下一个元素的开始索引, 元素2~9对应的开始索引分别为 {0, 2, 4, 4, 4, 5 ,7, 8}。元素的开始索引可以通过如下转换得到。

for (int r = 0; r < R; r++) {
    count[r +1] += count[r];
}

容易知道某元素的开始索引恰好是小于它的元素个数,那么count[r+1] = count[r+1] + count[r],这个式子表达的意思是:小于当前元素r+1的个数等于上一个元素的个数(count[r+1])与小于上一个元素的个数(count[r])之和。这样的更新方式保证了count[0]始终等于0(第一个元素的开始索引肯定是0)。现在来说为什么一开始将count[]数组的长度多设置1。因为在统计每个元素的频率时count[]将每个元素减去最小值再加1作为索引,因此count[0]永远也不会被赋值,这保证count[0]始终为0,而且使用加1后的索引在进行上面的循环时,转换成开始索引数组能得到正确的结果。所有加1操作都是为了能得到语义明确的开始索引数组!

于是通过上面的循环,得到开始索引数组为count[] = {0, 2, 4, 4, 4, 5 ,7, 8, 10},最后一位10是用不到的,它表示的含义是下一个元素(实际不存在)开始的索引。假如想知道元素7的开始索引,只需count[7-min] = count[7-2] = 5,就能知到第一个7位于数组索引5的位置。原数组排序后是[2, 2, 3, 3, 6, 7, 7, 8, 9, 9],元素7确实是从数组第5个位置开始的。

利用count[]数组,可以根据每个元素的开始索引,一个个将其填充到临时数组中的对应位置,最后再将临时数组中的数据写回原数组即可。

实现如下

package Chap9;

import java.util.Arrays;

/**
 * 计数排序
 */
public class CountingSort {
    public static void sort(int[] arr) {
        // 计算最大最小值,严谨实现最好用ifPresent检查下
        int max = Arrays.stream(arr).max().getAsInt();
        int min = Arrays.stream(arr).min().getAsInt();
        int N = arr.length;
        int R = max - min + 1; // 最大最小元素之间范围[min, max]的长度
        // 1. 计算频率,在需要的数组长度上额外加1
        int[] count = new int[R+1];
        for (int i = 0; i < N; i++) {
            // 使用加1后的索引,有重复的该位置就自增
            count[arr[i] - min + 1]++;
        }
        // 2. 频率 -> 元素的开始索引
        for (int r = 0; r < R; r++) {
            count[r + 1] += count[r];
        }

        // 3. 元素按照开始索引分类,用到一个和待排数组一样大临时数组存放数据
        int[] aux = new int[N];
        for (int i = 0; i < N; i++) {
            // 填充一个数据后,自增,以便相同的数据可以填到下一个空位
            aux[count[arr[i] - min]++] = arr[i];
        }
        // 4. 数据回写
        for (int i = 0; i < N; i++) {
            arr[i] = aux[i];
        }
    }

    public static void main(String[] args) {
        int[] a = {9, 7, 6, 3, 9, 2, 7, 3, 2, 8};
        CountingSort.sort(a);
        System.out.println(Arrays.toString(a));
    }
}

运行后将打印[2, 2, 3, 3, 6, 7, 7, 8, 9, 9]说明实现没有问题。

你会发现,计数排序的时候没有使用a.compareTo(b)这样的代码,这说明计数排序不是基于比较的排序法

计数排序是稳定的排序算法。稳定性是由下面这句保证的

aux[count[arr[i] - min]++] = arr[i];

如果有两个相同的数据,假设前一个数据填入到数组的i位置;填入之后才i自增,因而相同的下一个数据会填入到数组的i + 1位置,这就保证了原本排在前面的相同元素在填入临时数组中后,依然排在前面的位置,它们的相对顺序没有变化。

上面的实现中求max和min,主要是为了尽量节省空间。试想[1003, 1001, 1030, 1050]这样的数据要排序,真的需要建立长度为1050 + 1的数组吗?按照我们的实现只需要长度为1050 - 1003 + 1= 48的数组(先不考虑额外+1的长度),就能囊括从最小到最大元素之间的所有元素了。然后在往count[]中写入值和读取值的时候每个元素都会减去min作为索引,强行将最小元素的索引变成0,于是待排序数组映射成[2, 0, 27, 47],这样所有元素都可以放进count[]而不会引发数组脚标越界。这种关系可以看成一种线性映射。

通过上面的分析,可以猜到,如果待排序数组的元素值跨度很大,比如[99999, 1, 2],为三个元素排序居然要使用99999 - 1 + 1的空间,实在是浪费。所以计数排序适用于待排序元素值分布较连续、跨度小的情况。计数排序的应用范围并不广,对整数或者字符(我们知道字符可以通过ASCII码表转换成整数)排序或许是个不错的选择。我们后面会学习到,计数排序可以作为基数排序的基础,使用基数排序可以实现低位优先(LSD)的字符串排序!

上面实现中,max、min的使用,可能加深了理解基数排序的难度。下面针对一个小范围内的值(都由1, 2 , 3 ,4组成),我们摒弃了max和min的使用,直接使用(最大元素值 + 1) + 1(这里是6)作为count[]数组的长度,和上面的实现相比只是浪费了一个数组位置而已(下面将看到count[1]的值总是为0),带来的影响是微不足道的——但却能更好的帮助我们理解计数排序算法本身。

老师在统计学生的分数时可能会把给学生分成若干组,标号为1、2、3、4等。如下图所示

image

数组中的内容是学生的姓名,他们每个人都有一个标号,我们真正要排序的是这些标号。学生的标号可以通过a[i].key()获得。

下图统计了每个标号的频率,并转换成了开始索引表。仔细看count[1]始终是0,因为没有标号0。这就是上面说到的浪费的一个位置。

image

下图显示了根据开始索引数组,将元素分类到临时数组中的过程。加粗的部分是元素为3的条目,可以清晰地看出,随着count[3]的自增,每个值为3的元素是如何填充到aux中的连续位置的。

image

下图则反映了在数据填充过程中,count[i]的自增轨迹。

image

怎么样,不用max、min是不是更好理解了?对比上面几幅图中的代码,再回头看我们的代码,其实就是在读取或写入count[]时候,索引全都减去了min而已!

桶排序

桶排序的思想:

  • 根据输入建立适当个数的桶,每个桶可以存放某个范围内的元素;
  • 将落在特定范围内的所有元素放入对应的桶中;
  • 对每个非空的桶中元素进行排序,可以选择通用的排序方法,比如插入、快排;
  • 按照划分的范围顺序,将桶中的元素依次取出。排序完成。

举个例子,假如被排序的元素在0~99之间,我们可以建立10个桶,每个桶按范围顺序依次是[0, 10)、[10, 20]......[90, 99),注意是左闭右开区间。对于待排序数组[0, 3, 2, 80, 70, 75, 72, 88],[0, 3, 2]会被放到[0, 10)这个桶中,[70 ,75, 72]会被放到[70, 80)这个桶中,[80, 88]会被放到[80, 90)这个桶中,对这三个桶中的元素分别排序。得到

  • [0, 10)桶中的元素: [0, 2, 3]
  • [70, 80)桶中的元素: [70, 72, 75]
  • [80, 90)桶中的元素: [80, 88]

依次取出三个桶中元素,得到序列[0, 2, 3, 70, 72, 75, 80, 88]已经排序完成。

可以用一个数组bucket[]存放各个桶,每个桶用链表表示,用于存放处于同一范围内的元素。上面建立桶的方法是根据输入范围为0~99,建立了10个桶,每个桶可以装入10个元素,这将元素分布得很均匀,在桶排序中保证元素均匀分布到各个桶尤为关键。举个反例,有数组[0, 9, 4, 5, 8, 7, 6, 3, 2, 1]要排序,它们都是10以下的数,如果还按照上面的范围[0, 10)建立桶,全部的元素将进入同一个桶中,此时桶排序就失去了意义。实际情况我们很可能事先就不知道输入数据是什么,为了保证元素均匀分不到各个桶中,需要建立多少个桶,每个桶的范围是多少呢?为此指定一个简单通用的规则:

假设待排序数组为arr,长度为arr.length;任意元素用value表示,其中的最大元素为maxValue

  • 建立的桶个数与待排序数组个数相同,这个简单的数字虽然大多数情况下会浪费许多空间(很多桶是空的),但也正因为桶的数量多,也很好地避免了大量元素都装入同一个桶中的情况。
  • 对于待排序数组中每个元素,使用如下映射函数将每个元素放到合适的桶中。这相当于每个桶能装的元素个数为(maxValue + 1) / arr.length.下式中maxValue加1是为了保证最大元素可以存到数组最后一个位置,即arr.length - 1处。
bucketIndex = (value * arr.length) / (maxValue + 1)

要注意:如何选择桶的个数,以及使用哪个映射函数将元素转换成桶的索引都是不一定的。上面的规则只是一种简单易懂的方法而已。

桶排序的实现如下

package Chap9;

import java.util.*;

/**
 * 桶排序
 * 桶的数量为数组长度arr.length
 * 映射函数使用 bucketIndex = (value * arr.length) / (maxValue + 1) ,加1是为了保证最大元素可以存到数组最后一个位置
 */
public class BucketSort {
    public static void sort(int[] arr) {
        // 建立桶,个数和待排序数组长度一样
        int N = arr.length;
        LinkedList<Integer>[] bucket =(LinkedList<Integer>[]) new LinkedList[N];

        // 待排序数组中的最大值
        int maxValue = Arrays.stream(arr).max().getAsInt();
        // 根据每个元素的值,分配到对应范围的桶中
        for (int i = 0; i < arr.length; i++) {
            int index = toBucketIndex(arr[i], maxValue, N);
            // 没有桶才建立桶(延时)
            if (bucket[index] == null) {
                bucket[index] = new LinkedList<>();
            }
            // 有桶直接使用
            bucket[index].add(arr[i]);
        }

        // 对每个非空的桶排序,排序后顺便存入临时的List,则list中已经有序)
        List<Integer> temp = new ArrayList<>();
        for (int i = 0; i < N; i++) {
            if (bucket[i] != null) {
                Collections.sort(bucket[i]);
                temp.addAll(bucket[i]);
            }
        }

        // 将temp中的数据写入原数组
        for (int i = 0; i < N; i++) {
            arr[i] = temp.get(i);
        }
    }

    // 映射函数,将值转换为应存放到的桶数组的索引
    private static int toBucketIndex(int value, int maxValue, int N) {
        return (value * N) / (maxValue + 1);
    }

    public static void main(String[] args) {
        int[] a = {44, 67, 32, 21, 9, 98, 44, 111, 99, 6};
        BucketSort.sort(a);
        System.out.println(Arrays.toString(a));
    }
}

运行程序会打印[6, 9, 21, 32, 44, 44, 67, 98, 99, 111]

桶排序可以是稳定的。这取决于我们对每个桶中的元素采取何种排序方法,比如桶内元素的排序使用快速排序,那么桶排序就是不稳定的;如果使用的是插入排序,桶排序就是稳定的。

桶排序也不能很好地应对元素值跨度很大的数组。比如[3, 2, 1, 0 ,4, 8, 6, 999],按照上面的映射规则,999会放入一个桶中,剩下所有元素都放入同一个桶中,在各个桶中元素分布极不均匀,这就失去了桶排序的意义。

桶排序和计数排序有个共同的缺点:耗费大量空间。

再细看桶排序,其实计数排序可以看作是桶排序的一种特例,计数排序相当于将所有相同的元素放入同一个桶中,而桶排序可以将一定范围内的元素都放入同一个桶中;另外,桶排序的数据结构很像基于拉链法的散列表,只是定义的映射函数不同。桶排序的映射函数将较大值映射成较大的索引,这两者是呈正相关的。而散列表的映射函数得到的哈希值是随意的。

基数排序

常见的数据元素一般是由若干位组成的,比如字符串由若干字符组成,整数由若干位0~9数字组成。基数排序按照从右往左的顺序,依次将每一位都当做一次关键字,然后按照该关键字对数组排序,每一轮排序都基于上轮排序后的结果;最后一轮,最左边那位也作为关键字并排序,整个数组就达到有序状态。比如对于数字2985,从右往左就是先以个位为关键字进行排序,然后是十位、百位、千位,总共需要四轮。一定要注意每一轮排序中排序方法必须是稳定的。否则基数排序不能得到正确的结果。

举个简单例子,对于数组a[] = {45, 44, 37, 28},先以个位为关键字对数组进行稳定的排序。得到[44, 45, 37, 28],只看个位是有序的;再对十位进行稳定的排序,得到[28, 37, 44 ,45],此时所有位都已作为关键字排序过一次,排序完成。再顺便说说为什么要求每轮排序是稳定的:假设每轮排序不是稳定的,对个位排序还是[44, 45, 37, 28],对十位排序时,44、45十位相同,不稳定的排序有可能改变它们原来的相对位置,排序后可能就变成了[28, 37, 45, 44],这样的结果显然不是我们期望的。

现在来说说基数是什么意思,对于十进制整数,每一位都只可能是0~9中的某一个,总共10种可能。那10就是它的基,同理二进制数字的基为2;对于字符串,如果它使用的是8位的扩展ASCII字符集,那么它的基就是256。既然我们知道每一位的数值范围。那么使用计数排序以关键字对数组进行排序就是个十分明智的选择,原因如下

  • 对于元素的每一位(关键字),计数排序都可以统计其频率,然后直接将整个元素按照该关键字进行分类、排序,实现起来简单。(想想插入排序、归并排序等稳定排序算法要如何按照某一位来将整个元素排序,是不是更复杂?)
  • 因为数据范围确定且都不大(基的大小),因此不会占用多少空间;
  • 而且计数排序不是基于比较,比通常的比较排序方法效率更高;
  • 计数排序是稳定排序,这一点至关重要。

基于此实现基数排序,如下

package Chap9;

import java.util.Arrays;

public class RadixSort {
    public static void sort(int[] a) {
        // 每位数字范围0~9,基为10
        int R = 10;
        int N = a.length;
        int[] aux = new int[N];
        int[] count = new int[R+1];
        // 以关键字来排序的轮数,由位数最多的数字决定,其余位数少的数字在比较高位时,自动用0进行比较
        // 将数字转换成字符串,字符串的长度就是数字的位数,字符串最长的那个数字也拥有最多的位数
        int W = Arrays.stream(a).map(s -> String.valueOf(s).length()).max().getAsInt();

        // 共需要d轮计数排序, 从d = 0开始,说明是从个位开始比较,符合从右到左的顺序
        for (int d = 0; d < W; d++) {
            // 1. 计算频率,在需要的数组长度上额外加1
            for (int i = 0; i < N; i++) {
                // 使用加1后的索引,有重复的该位置就自增
                count[digitAt(a[i], d) + 1]++;
            }
            // 2. 频率 -> 元素的开始索引
            for (int r = 0; r < R; r++) {
                count[r + 1] += count[r];
            }

            // 3. 元素按照开始索引分类,用到一个和待排数组一样大临时数组存放数据
            for (int i = 0; i < N; i++) {
                // 填充一个数据后,自增,以便相同的数据可以填到下一个空位
                aux[count[digitAt(a[i], d)]++] = a[i];
            }
            // 4. 数据回写
            for (int i = 0; i < N; i++) {
                a[i] = aux[i];
            }
            // 重置count[],以便下一轮统计使用
            for (int i = 0; i < count.length; i++) {
                count[i] = 0;
            }

        }
    }
    // 根据d,获取某个值的个位、十位、百位等,d = 0取出个位,d = 1取出十位,以此类推。对于不存在的高位,用0补
    private static int digitAt(int value, int d) {
        return (value / (int) Math.pow(10, d)) % 10;
    }

    public static void main(String[] args) {
        int[] a = {244, 167, 1234, 321, 29, 98, 1444, 111, 99, 6};
        RadixSort.sort(a);
        System.out.println(Arrays.toString(a));
    }
}

显而易见,基数排序是稳定排序。

基数排序也适用于字符串,若字符串使用的是8位的ASCII扩展字符集,则基的大小是256。下一篇文章将看到,只需对上面的代码稍作修改,就能实现对字符串的排序,基于基数排序的字符串排序方法称为低位优先的字符串排序(LSD).


by @sunhaiyu

2017.11.18

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容