【算法理解】——计数排序

前言

    大家好,相隔许久,甚是想念表情。已经有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、数组acounter是怎么关联的?为什么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元素之前的数字预留空间,达到了节约内存的效果。

    然后,有朋友应该会发现,还是存在内存浪费的情况。因为这里的做法是声明一个从minmaxcounter数组,那如果a的内容是{5,3,1}的话,counter数组实际上的内容是{1,0,1,0,1},因为counter给元素2和4预留了空间,但实际上2和4在a中不存在。

    那么为什么counter要声明从minmax的所有空间呢?原因是:为了达到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中的minmax;然后创建从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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值