前言
大家好,相隔许久,甚是想念。已经有9个月左右没有写博客了,因为之前的【算法理解】模块的编写风格都比较接近,也比较花时间。有博友发邮件跟我反馈了一下,这种带入数据式的讲解,需要比较大的精力和耐心细看才能看懂,不是很合适。
所以本篇算法理解,本人将用不同的方式对该算法进行讲解。如果不合适,希望大家踊跃的批评反馈。
其实选择该算法进行讲解,是因为该算法比较特殊:
1、大部分算法都需要依赖于“数据两两比较”进行排序,而该算法没用任何比较操作;
2、该算法的排序核心理念很特殊。
废话不多说,进入正文。
算法介绍
众所周知,排序算法(大部分)都是依赖“数据两两比较”的结果,然后按照某种条件进行交换,最终达到排序效果。但是该算法并没有依赖比较而进行排序,那到底是怎样进行排序的吗?
首先,需要验证下面一个命题:
在一个元素不重复的有序数组中,任取一个元素a[i],假设比元素a[i]小的元素一共有k个,那么a[i]的下标就是k。
证明:
{1, 2, 3, 4, 5}中,取一个元素2,比2小的元素有1个,所以2在有序数组中的下标为1;
另取一个元素4,比4小的元素有3个,所以4在有序数组中的下标为3。
相信大家都能够理解上面的命题。但是实际工作中,无法保证数组无重复(关于有重复数据的,稍后会进行讲解,先从Easy难度开始);并且有人应该会疑惑:“如果数组已经有序了,那咱们还需要排序干嘛”。
那么接下来这句话,既可以回答上面的问题,同时又概括了本算法的核心理念:
在数组a中,如果知道了一共有k个元素小于a[i],则可以准确的推断出,排序之后a[i]的下标为k。
该算法就是通过上面的核心理念来达到排序效果的,那究竟是怎样排序的呢?咱们往下看。
算法源码
private static int[] sort(int[] a) {
// 1, 创建计数器数组,用于存储排序之后的结果
int[] b = new int[a.length];
// 2, 计算计数器数组的大小
int min = a[0], max = a[0];
for (int val : a) {
if (val < min) min = val;
if (val > max) max = val;
}
int[] counter = new int[max - min + 1];
// 3, 统计数组a中,每一个元素出现的次数,并在计数器数组中记录下来
for (int i = 0; i < a.length; i++) {
counter[a[i] - min]++;
}
// 4, 统计小于等于当前元素的个数
for (int i = 1; i < counter.length; i++) {
counter[i] = counter[i] + counter[i - 1];
}
// 5, 给排序后的数组赋值
for (int i = b.length - 1; i >= 0; i--) {
b[--counter[a[i] - min]] = a[i];
}
return b;
}
算法理解(无重复数据的数组)
通过上面的代码注释可以 看到,一共分为5个步骤。从步骤3以后就开始有点难以理解。与以往不同,不从步骤1开始讲解,而先从步骤5开始:
在步骤5中,我们先别急着去研究复杂的b[- -counter[a[i] - min]]的含义,很明显,就是一个b[x] = a[i]的赋值表达式,因为b是作为排序之后的数组返回,我们可以推断:- -counter[a[i] - min]是a[i]元素排序之后的下标
然后再通过本算法的核心理念又可以推断出:- -counter[a[i] - min]是小于a[i]的元素的个数。
那咱们再来看看上面的表达式
// 5, 给排序后的数组赋值
for (int i = b.length - 1; i >= 0; i--) {
b[--counter[a[i] - min]] = a[i];
}
/** 将其细化一下,可以得到下面的内容 **/
for (int i = b.length - 1; i >= 0; i--) {
// a, 获取当前元素的值
int val = a[i];
// b, 获取当前元素在计数器中的下标(关于为什么要-min,稍微看一下步骤3应该可以明白;如不明白,后面也会讲解)
int index = val - min;
// c, 获取小于当前的元素val的元素个数,也就是val元素在数组排序之后的下标
int sortedIndex = counter[index] - 1; // 仅需要“小于当前元素的个数”,由于包含了自己,所以-1
// d, 赋值到排序后的数组中
b[sortedIndex] = val;
// e, 将计数器中,当前元素对应的个数-1
// 这一步操作,在无重复数据的数组中,是没有意义的,暂时忽略,稍后介绍
counter[index]--;
}
步骤c中sortedIndex就是- -counter[a[i] - min],也就是我们需要的比a[i]小的元素的个数,那么在分析这个表达式之前,我们首先得知道:
1、数组counter是干什么的;
2、数组a和counter是怎么关联的?为什么a[i] - min能作为counter中元素的下标?
要了解counter是干什么的,我们得从步骤2开始说起。
// 2, 计算计数器的大小
int min = a[0], max = a[0];
for (int val : a) {
if (val < min) min = val;
if (val > max) max = val;
}
int[] counter = new int[max - min + 1];
// 3, 记录数组a中,每一个元素出现的次数
for(int i = 0; i < a.length; i++) {
counter[a[i] - min]++;
}
步骤2明显是找到数组中的最大值和最小值,然后通过max - min + 1获得从最小值到最大值之间,一共有多少个数字,并用该值声明counter计数器的大小;
步骤3,实际上就是针对数组中的每一个元素的出现次数,进行计数。那为什么是a[i] - min作为下标,而不是直接使用a[i]作为下标呢?接下来,我们要做一个假设:
现有数组{80, 81, 82, 83, 84},如果现在需要用a[i]直接获取元素对应的计数器并进行计数的话,需要创建一个{0, 1, 2, … , 83, 84}的数组,这样才能直接使用a[i]直接获取元素对应计数器的下标,这样就相当浪费内存空间(因为实际上0 ~ 79的元素都是0,并且没有使用);
而如果使用a[i] - min获取元素对应的计数器的话,仅需要创建一个a.length长度的计数器数组即可。例如:上面假设的数组中,a[0]元素(也就是80)对应的计数器的下标是 80 - min,在数组中min的值也是80,所以a[0]元素对应计数器的下标是80 - 80,也就是counter[0];a[4]元素对应的计数器的下标是84 - 80,也就是counter[4]。所以仅需要创建数组{0, 1, 2, 3, 4}即可。
所以,使用a[i] - min作为下标,计数器数组无需为min元素之前的数字预留空间,达到了节约内存的效果。
然后,有朋友应该会发现,还是存在内存浪费的情况。因为这里的做法是声明一个从min到max的counter数组,那如果a的内容是{5,3,1}的话,counter数组实际上的内容是{1,0,1,0,1},因为counter给元素2和4预留了空间,但实际上2和4在a中不存在。
那么为什么counter要声明从min到max的所有空间呢?原因是:为了达到counter数组有序。
举例说明,数组a的值为{5, 1, 3},数组counter中的内容是{1, 0, 1, 0, 1},而counter中计数器所对应a中的元素是{1, 2, 3, 4, 5},达到了数组有序的效果。
那么,为什么counter数组要有序呢?我们接着看步骤4.
// 4, 统计小于等于当前元素的个数
for (int i = 1; i < counter.length; i++) {
counter[i] = counter[i] + counter[i - 1];
}
就像代码注释所说,实际上就是修改计数器的值。counter数组从原来用于【记录数组 a 中每个元素出现的次数】变成了【记录数组 a 中小于等于当前元素的个数】。
例如:{5, 3, 1}对应的计数器数组内容是{1, 0, 1, 0, 1},经过了步骤4之后,内容变成了{1, 1, 2, 2, 3}。
通过上面代码,计算小于等于当前元素的个数有一个条件,就是counter数组有序,否则无法通过上面的代码达到预期效果。
所以,通过步骤4,我们就把a[i]和小于等于a[i]的元素个数关联上了,可以通过a[i]获取小于等于a[i]的元素的个数:
a、获取a[i]的值;
b、使用a[i] - min作为下标,去counter数组中获取元素;
c、从counter中获取出来的元素,就是小于等于a[i]元素的个数。
为什么还要算上等于当前元素的个数呢?不是仅仅需要小于当前元素的个数就可以了吗?
这个问题,在无重复元素的数组中,可以忽略,因为仅需要-1就可以了,稍后会在重复元素的数组的时候介绍。
最后,我们再来分析一下步骤5中的表达式。
// 5, 给排序后的数组赋值
for (int i = b.length - 1; i >= 0; i--) {
b[--counter[a[i] - min]] = a[i];
}
表达式- -counter[a[i] - min]的内容,也就是counter[a[i] - min] - 1的值。counter[a[i] - min] 就是获取小于等于当前元素的个数;然后-1操作,在无重复的数组中,相当于扣掉了等于当前元素的个数。
这样,就直接通过小于当前元素的个数,来精确定位排序之后的下标,对数组b进行赋值,最终达到排序效果。
算法理解(有重复数据的数组)
好了,其实在之前,咱们遗留了几个问题没有分析:
1、如果数组a有重复数据,那么知道有k个元素小于a[i],能确定a[i]排序之后的下标是k吗?
2、步骤4中,为什么要加上等于当前元素的个数呢?不是只需要小于当前元素的个数就可以确定排序之后的下标了吗?
3、步骤5中,为什么要让- -counter[a[i] - min]自减,也就是说,为什么赋值之后要将对应的counter[i]计数器自减?
好的,关于上面3个问题,实际上可以用同一个案例进行解释,下面开始举例说明。
现有数组 int[] a = { 3, 3, 3, 2, 1}, 创建数组 int[] b = { 0, 0, 0, 0, 0}用于接收排序之后的内容。
根据步骤2、3,可以得到计数器数组 int[] counter = { 1, 1, 3 }(数组a中,每个元素出现的次数),经过步骤4后,counter的内容变为 { 1, 2, 5 }(数组a中,小于等于当前元素的个数)。
最后,通过步骤5,对数组b进行赋值。如果按照之前的说法,都减1得到下标,那么数组a中的元素的下表分别是:
a[0] = 3 ——> counter[3 - 1] = 5 ——> 小于等于3的个数5 ——> b[5 - 1] = 3
a[1] = 3 ——> counter[3 - 1] = 5 ——> 小于等于3的个数5 ——> b[5 - 1] = 3
a[2] = 3 ——> counter[3 - 1] = 5 ——> 小于等于3的个数5 ——> b[5 - 1] = 3
a[3] = 2 ——> counter[2 - 1] = 2 ——> 小于等于3的个数2 ——> b[2 - 1] = 1
a[4] = 1 ——> counter[1 - 1] = 1 ——> 小于等于3的个数1 ——> b[1 - 1] = 0所以,最终返回的有序数组b的内容是 {1, 2, 0, 0, 3}
WTF? 还有两个元素3跑哪去了呢? 覆盖了。换句话说,就是丢失了。
其实- -counter[a[i] - min]已经对上面出现的问题进行处理了。
还是上面的例子, 从步骤5开始
a[0] = 3 ——> - -counter[3 - 1] = 4 ——> 小于3的个数4 ——> b[4] = 3
a[1] = 3 ——> - -counter[3 - 1] = 3 ——> 小于3的个数3 ——> b[3] = 3
a[2] = 3 ——> - -counter[3 - 1] = 2 ——> 小于3的个数2 ——> b[2] = 3
a[3] = 2 ——> - -counter[2 - 1] = 1 ——> 小于2的个数1 ——> b[1] = 1
a[4] = 1 ——> - -counter[1 - 1] = 0 ——> 小于1的个数0 ——> b[0] = 0所以,最终返回的有序数组b的内容是 {1, 2, 3, 3, 3}
到这里,可以针对上面提出的三个问题进行回答:
1、依然能够确定,但是需要通过与counter进行计算,否则,将导致重复的元素在同一个下标上(数据丢失);
2、之所以加上等于当前元素的个数,就是为了通过计算,防止问题1中的数据丢失;
3、赋值之后,让counter[a[i] - min]自减,是为了防止相同的元素,在同一个下标上出现。
上面的三个问题,实际上都用于处理同一个问题。简单来说,就是为了防止重复数据在同一个下标上出现,而导致数据丢失。
算法总结
private static int[] sort(int[] a) {
// 1, 创建数组,用于存储排序后的数组
int[] b = new int[a.length];
// 2, 计算计数器的大小
int min = a[0], max = a[0];
for (int val : a) {
if (val < min) min = val;
if (val > max) max = val;
}
int[] counter = new int[max - min + 1];
// 3, 记录数组 a中,每一个元素出现的次数
for (int i = 0; i < a.length; i++) {
counter[a[i] - min]++;
}
// 4, 统计小于等于当前元素的个数
for (int i = 1; i < counter.length; i++) {
counter[i] = counter[i] + counter[i - 1];
}
// 5, 给排序后的数组赋值
for (int i = b.length - 1; i >= 0; i--) {
b[--counter[a[i] - min]] = a[i];
}
return b;
}
从头回忆一下,本算法包括下面几个步骤:
1、创建数组b,用于接收排序之后的数组,并返回;
2、为了达到counter有序,计算数组a中的min和max;然后创建从min开始,到max结束的计数器数组counter;
3、从min开始,到max结束,记录a数组中的每一个元素出现的次数;
4、因为counter数组有序,可以从min + 1开始,用counter[i] + counter[i - 1]来记录小于等于当前元素的个数;
5、找到a[i]所对应的计数器的值,也就是小于等于a[i]的个数,作为在有序数组b中的下标进行赋值;
同时,将a[i]对应的计数器的值减1,防止相同元素出现在有序数组b的同一下标上。
算法优劣势
优势:效率相当高。在一个大小为N的数组中,比较次数为2N,交换(赋值)次数为N,时间复杂度为线性级别(O(n));
劣势:消耗额外空间。
在数据均匀的情况下,本算法的效率高于任何基于“比较”的算法;而在数据不均匀的情况下,将导致空间过分浪费而效率低下。例如:
现有数组{ 1, 500, 1000},那么通过步骤2,将产生一个{0, 1, … , 999}的数组,实际上大部分计数器并未使用,而造成空间浪费。
所以,本人认为是否使用本算法进行排序的依据是:数组数据均匀程度、内存空间限制。
总结
最后上传几张各大排序针对于10000随机数据进行排序的耗时,来体现本算法强大的效率。
【选择排序】 【插入排序】 【希尔排序】
【归并排序】 【快速排序】 【堆排序】
【计数排序】
纳尼…,这惊人的效率,简直强大的可怕…
好了,到这里为止,关于【计数排序】算法的基本实现和理解已经分析完毕。本人写这篇博文,还是因为感慨该算法独特的排序理念。如果关于该算法有其他不同的理解或者实现方式,可以加本人QQ交流交流。
另外,如果本博文在讲述方式或者其他地方有问题的话,也欢迎大家批评指出。
最后,由于本人最近被真爱蓝(雷姆)迷的神魂颠倒的,献上一张美图,送给各位奋斗在一线的皆さん.
个人邮箱:444208472@qq.com