计数排序
计数排序有如下四个步骤。
- 首先会对每个输入进行频率统计,得到元素的频率表;
- 然后将频率表转换为该元素的开始索引;
- 根据各个元素的开始索引,将相同元素分类到临时数组中。
- 最后将临时数组中的元素写回到原数组中。
上面介绍了计数排序的流程,举个例子,要对[9, 7, 6, 3, 9, 2, 7, 3, 2, 8]排序。首先建立频率表,为了尽量节省空间,需要找到待排序数组中最大元素和最小元素,分别是2和9,该区间共有9 - 2 + 1 = 8种可能的值。为此建立一个长度为8 +1的数组,为什么多加1,后面会说到。
于是通过将待排序的元素计数,得到如下频率表。
每个元素减去最小值再加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~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等。如下图所示
数组中的内容是学生的姓名,他们每个人都有一个标号,我们真正要排序的是这些标号。学生的标号可以通过a[i].key()获得。
下图统计了每个标号的频率,并转换成了开始索引表。仔细看count[1]始终是0,因为没有标号0。这就是上面说到的浪费的一个位置。
下图显示了根据开始索引数组,将元素分类到临时数组中的过程。加粗的部分是元素为3的条目,可以清晰地看出,随着count[3]的自增,每个值为3的元素是如何填充到aux中的连续位置的。
下图则反映了在数据填充过程中,count[i]的自增轨迹。
怎么样,不用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).