排序超详解---一篇文章带你掌握所有实用的排序方法

     

目录

一。冒泡排序(Bubblesort)

二。选择排序(Selectsort)

三。插入排序(Insertsort)

四。希尔排序(Shellsort)

实现方法1:(双循环法)

实现方法2:(单循环法)

五。快速排序(Quicksort)递归形式

霍尔法(左右指针法)(hoare法)

挖坑法

前后指针法

优化1:随机数法

优化2:三数取中

优化3:小区间优化

快速排序的非递归形式

六。堆排序(Heapsort)

升序建小堆方法(改良版)

七。归并排序(递归形式)(Mergesort)

 归并排序的非递归形式(排序最难的内容)

八。计数排序(Countsort)

计数排序的缺点

附录:C语言栈的实现(仅供参考)


 

本文主要是对我已知的所有排序方法进行总结。排序我们很早在学C语言的时候就接触过,比如冒泡排序,qsort的底层逻辑就是快速排序。我们今天不是要翻旧账,而是要把排序一家一锅全端了。本节内容难度适中,就是有点费脑,请最好先学习了数据结构的相关知识再来观看此文。话不多说我们现在开始:

一。冒泡排序(Bubblesort)

根据以上动图可以得出冒泡排序的精髓就是用前一个和后一个比,如果前一个数大于后一个数就交换一下,然后如此操作n个数据就会有n - 1次冒泡排序(每次排在最后的数据就不再参与下一轮的冒泡排序,只剩下一个数据就不用排了,所以是n - 1次),每次冒泡排序都会把要排的一堆数的最大的数排在最后。实现代码如下:(升序)

#include<stdio.h>
//冒泡排序
void swap(int* a, int* b)
{
    int c = 0;
    c = *a;
    *a = *b;
    *b = c;
}
void Bubblesort(int* a, int n)
{
    for (int i = 0; i < n - 1; i++)
//冒泡排序的趟数
    {
        int count = 0;
        for (int j = 0; j < n - i - 1; j++)
//由于前一次冒泡排序会比后一次冒泡排序少排一个数,所以要减i,由于只有第一趟冒泡排序是考虑了所有数,所以减i,i初始值为0也没有问题
        {
            if (a[j] > a[j + 1])
            {
                swap(&a[j], &a[j + 1]);
                count++;
            }
        }
        if (count == 0)
//如果第一轮没有交换则说明原本的数据就是升序的,就不需要排序了
        {
            break;
        }
    }
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int arr[10] = { 3,6,1,5,8,9,2,7,4,0 };
    int arr2[10] = { 3,6,1,5,8,8,2,7,4,0 };
    Bubblesort(arr, 10);
//函数名不要用拼音,bubble是冒泡的英文
    //Bubblesort(arr2, 10);
    return 0;
}

小结:所有排序方法都不是一次性写成的,都是先写单趟排序再演变成多趟的书写。这边测试两种数据,一种为数据没有重复的如arr,一种为数据有重复的如arr2,可以看到都没有什么问题,说明我们这个排序的写法没错。能排升序就肯定可以排降序的,代码稍微改动一下罢了。

时间复杂度分析:冒泡排序的最坏情况就是原本数据已经是降序的结果要排升序,下面那个循环都走了从n-1到0次,由于呈等差数列,所以加起来的总和为1/2 * n^2 所以时间复杂度为O(n^2)

空间复杂度分析:由于没有开辟新的空间也没有开辟新的函数栈帧,所以为O(1)

稳定性:由于前后两个相等的值不会因为冒泡排序而改变位置,所以冒泡排序稳定

二。选择排序(Selectsort)

根据以上动图可以得出选择排序的原理是遍历数组的元素然后选出里面的最小的然后和最前面的值交换,这样第一个数据就排好了,从第2趟开始就从还没有排的数据的第一个开始排列。

由于以上动图是只选择最小的数,我们为了提高排序的效率,可以发现没次选择排序可以同时选出最大和最小的数,最小的排在待排数组的最左边,最大的排在代排数组的最右边,然后下一次参与选择排序的数组的长度就头下标加1尾下标减1就可以(已经排好的就不用排了),等到头下标等于尾下标时说明数组只剩下一个数据可排也就没有必要再进行选择排序了。实现代码如下:(升序)

void Selectsort(int* a, int n)
{
    int begin = 0;
//头下标
    int end = n - 1;//尾下标
    while (begin < end)//结束条件
    {
        int min = begin;
//先令数组最开头为最小值
        int max = begin;//先令数组最开头为最大值
        for (int i = begin + 1; i <= end; i++)//从第2趟开始开头和末尾已经排好的不参与排序

        {
            if (a[i] < a[min])
//比较大小,比我们定义的最小值下标小就互换
            {
                min = i;
            }
            if (a[i] > a[max])
//比较大小,比我们定义的最大值的下标大就互换
            {
                max = i;
            }
        }
        swap(&a[min], &a[begin]);
//此时循环结束后当次选择排序就排出了最小值和最大值,然后将最小值换到未排序数据的开头,最大值换到未排序数据的结尾
        if (max == begin)//如果最大值刚好在未排数据的开头,那如果最小值先被换到开头的话,最大值就被换到原本最小值的位置,所以特殊情况特殊处理了,此时最大值的下标就要变成原本最小值的下标
        {
            max = min;
        }
        swap(&a[max], &a[end]);
        ++begin;
//排序范围修改
        --end;
    }
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int arr[10] = { 3,6,1,5,8,9,2,7,4,0 };
    int arr2[10] = { 3,6,1,5,8,8,2,7,4,0 };
    Selectsort(arr, 10);
    Selectsort(arr2, 10);
    return 0;
}

小结:所有排序方法都不是一次性写成的,都是先写单趟排序再演变成多趟的书写。这边测试两种数据,一种为数据没有重复的如arr,一种为数据有重复的如arr2,可以看到都没有什么问题,说明我们这个排序的写法没错。能排升序就肯定可以排降序的,代码稍微改动一下罢了。上述选择排序的细节明显会多一点,那个特殊情况是可以通过调试调出来的。

特别是这个地方刚刚接触的时候肯定是不容易想到的,可以通过调试的方式进行发现,调试时同时监视max,min,begin的值,慢慢一步一步走或者在if前面打个断点再按F5跳到断点处,细心观察一定可以观察出来的。

时间复杂度分析:由于前面的while循环的数据遍历是从两边向中间汇总的所以可以认为n个数据就是为1/2n,下面的for循环的数据遍历也是每次两边都少一个的,所以最坏情况也就是第一趟,由于选择排序越排到后面要比较的次数越少,相对前面的几次会比较接近n,后面几次就接近一个常数了,当n足够大时接近常数的趟数可以忽略不记,由于n前面的系数也可以忽略不记,所以就是n,由于两个循环是嵌套的所以时间复杂度就是O(n^2)

空间复杂度分析:由于没有开辟新的空间也没有开辟新的函数栈帧,所以为O(1)

稳定性:由于前后两个相等的值会因为选择排序而改变位置,所以选择排序不稳定。这个很好理解的我举个例子你就懂了:比如要参与选择排序的数组为3 3 1 1,那最小的为1,第一个1就要和第一个三互换,此时两个3的相对位置就发生了改变,所以选择排序不稳定。

三。插入排序(Insertsort)

插入排序的底层原理还是对于交换的原理,要排升序时,我们发现插入排序时,如果要排列的数的大于其前一个那就不用排了,要排列的数之前的部分一定是有序的,如果要排的数小于其前一个那就让其前一个数和它交换后,再比其前前一个,如此操作直到比到比要排的数小的数然后插入在那个比它小的数的前面。实现代码如下:(升序)

void Insertsort(int* a, int n)
{
    for (int i = 0; i < n - 1; i++)
//注意是n - 1不要越界了
    {
        int end = i;
//由于等下要进行的是数的交换操作,要用到下标的所以我们取已排序数组部分的结尾下标
        int tmp = a[end + 1];//每次考虑增排一个数,增排的数为已排数组部分的下一个数
        while (end >= 0)//已排数组部分的最小范围
        {
            if (tmp < a[end])
            {
                a[end + 1] = a[end];
                end--;
//如果比待排数的前一个小就比前前一个的所以--
            }
            else
            {
                break;
//如果比前一个大说明不用再排了直接break
            }
        }
        a[end + 1] = tmp;
//如果跳出循环了说明此时tmp大于a[end],所以tmp放在此时end的前一个位置
    }
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int arr[10] = { 3,6,1,5,8,9,2,7,4,0 };
    int arr2[10] = { 3,6,1,5,8,8,2,7,4,0 };
    Insertsort(arr, 10);
    Insertsort(arr2, 10);
    return 0;
}

小结:所有排序方法都不是一次性写成的,都是先写单趟排序再演变成多趟的书写。这边测试两种数据,一种为数据没有重复的如arr,一种为数据有重复的如arr2,可以看到都没有什么问题,说明我们这个排序的写法没错。排序细节请看下图,能排升序就肯定可以排降序的,代码稍微改动一下罢了。上述插入排序的细节明显更多一点。插入排序就足以体现先写单趟再完成多趟的重要性,不然 int end = i为多趟的写法我们都不推荐直接写成i,单趟应该是等于0。

时间复杂度分析:插入排序的第一个for循环控制趟数,所以为n - 1,也就是n,后面和for循环嵌套的while循环由于如果数据原本就是升序的又要排升序所以这种情况就相当于里面while循环每次都没有走所以最好情况为1,合起来时间复杂度为O(n)。但是如果数据原本为降序的现在插入排序要排升序,那代排的数在while循环中要走进行的步数为前面一排数组的元素个数,为n合起来时间复杂度为O(n)为O(n^2),所以最终时间复杂度取最坏的情况为O(n^2)

空间复杂度分析:由于没有开辟新的空间也没有开辟新的函数栈帧,所以为O(1)

稳定性:由于前后两个相等的值不会因为插入排序而改变位置,所以插入排序稳定

四。希尔排序(Shellsort)

希尔排序为插入排序的升级版,以人名命名的排序足以体现其重要性。

希尔排序的底层逻辑还是原本的插入排序,但是由上面插入排序的时间复杂度分析可得,插入排序的时间复杂度在数据之间的差别还是很大的,最小的是O(n),最大为O(n^2),造成n^2的原因是由于大量大的数据囤积在前面使得小的数据要换到前面来要比较多的时间。

所以希尔排序优化了插入排序的这个劣势,先进行一次预排序

预排序:分组预排插入,目标是让大的数更快的插入到后面,让小的数更快的换到前面,用gap来分组,分多少组依数据多少而不同,也就是说分多少组是不固定的,然后每一组的每个数的间距为gap,由于有数组长度和gap本身大小设置的不同,所以每一组的数据可以不同,可以相同,可以只有1个,每组进行插入排序。

依上图可以看到有gap等于几就会有gap组的数据参与预排,因为第一组数据肯定是从第一个数开始排的,第一组排的数据就是0   0 + gap  0 + gap + gap ……如果大于要排的数据大于gap就会发生重复排序的情况。所以总结得到gap等于多少就有gap组,每组的元素间隔是gap。

那gap初始值等于多少呢?gap越大排列的数据的间隔就越大,排的数据数就越少,就越不接近有序,gap越小排列的数据的间隔就越小,排的数据数就越多,就越接近有序,当gap等于1时就会将所有数据进行插入排序了。这个问题经过希尔的研究(不需要知道是怎么研究出来的)gap的初始值每次为n(元素个数的倍数为最佳),也就是说gap的值是不固定的,是待排数组的元素个数的倍数为最佳。所以我们可以让gap的初始值为n(元素总个数),然后每次预排序都将gap / 2,每组元素间隔gap / 2,排gap / 2组。直到n < 1时就不排了。反正最后gap都会等于1,当gap等于1时就相当于上面的插入排序了,也就是说当gap = 1时每个数据都会进行插入排序。

但是又有人经过精确的论证说让gap每次除3再+1,(加1是为了确保gap最后等于1进行所有数据的插入排序),为最佳,那我们不需要知道是怎么研究出来的,我们就采用每次都除3再+1的方法

实现代码如下:(升序)

实现方法1:(双循环法)

先将一组gap间隔的数据排好再排其他组,这样就需要两层循环,外循环控制趟数,内循环控制排的数据的位置和个数。

比如上图中gap == 3,就先排j == 0红色的这一组再排j == 1绿色的那一组,最后排j == 2紫色的那一组。

void Shellsort(int* a, int n)
{
    int gap = n;//初始化gap
    while (gap > 1)//控制gap的大小
    {
        gap = gap / 3 + 1;//由于是先判断再/3+1的所以gap等于1可以进行排序
        //gap /= 3 + 1;不要写成这样的
        for (int j = 0; j < gap; j++)//控制趟数
        {
            for (int i = j; i < n - gap; i += gap)//当gap为1时就相当于上面写的插入排序了,注意不要让tmp越界了i要小于n - gap
            {
                int end = i;
                int tmp = a[end + gap];
                while (end >= 0)
                {
                    if (tmp < a[end])
                    {
                        a[end + gap] = a[end];
                        end -= gap;
                    }
                    else
                    {
                        break;
                    }
                }
                a[end + gap] = tmp;
            }
        }
    }
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int arr[10] = { 3,6,1,5,8,9,2,7,4,0 };
    int arr2[10] = { 3,6,1,5,8,8,2,7,4,0 };
    Shellsort(arr, 10);
    Shellsort(arr2, 10);
    return 0;
}

实现方法2:(单循环法)

gap的值为分的组数,双循环法需要等到上一组完成排序后,下一组才开始排序,单循环法就是gap组一起进行相同步数的排序,这样i不断加1每次加1都会做为排序的开始,每个i都是排序的开始值的下标。

void Shellsort2(int* a, int n)
{
    int gap = n;//初始化gap
    while (gap > 1)//控制gap的大小
    {
        gap = gap / 3 + 1;//由于是先判断再/3+1的所以gap等于1可以进行排序
        //gap /= 3 + 1;

        for (int i = 0; i < n - gap; i++)//当gap为1时就相当于上面写的插入排序了,注意不要让tmp越界了i要小于n - gap,通过i不断加加来寻找每组排序的初始值的下标
        {
            int end = i;
            int tmp = a[end + gap];
            while (end >= 0)
            {
                if (tmp < a[end])
                {
                    a[end + gap] = a[end];
                    end -= gap;
                }
                else
                {
                    break;
                }
            }
            a[end + gap] = tmp;
        }
        
    }
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int arr[10] = { 3,6,1,5,8,9,2,7,4,0 };
    int arr2[10] = { 3,6,1,5,8,8,2,7,4,0 };
    Shellsort2(arr, 10);
    Shellsort2(arr2, 10);
    return 0;
}

小结:所有排序方法都不是一次性写成的,都是先写单趟排序再演变成多趟的书写。这边测试两种数据,一种为数据没有重复的如arr,一种为数据有重复的如arr2,可以看到两种实现方法都没有什么问题,说明我们这两个排序的写法没错。能排升序就肯定可以排降序的,代码稍微改动一下罢了。希尔排序的本质就是插入排序。

我本人会更推荐使用单循环法,因为时间复杂度看起来会比较小一点。

时间复杂度分析(建议直接死记):对于希尔排序的我们最开始的时间复杂度的猜想有很多:

O(n^2):有人认为既然希尔排序的底层结构是插入排序那希尔排序的时间复杂度会是O(n^2)吗,答案显然是不对的,会有这种想法说明你没有理解希尔排序最开始的预排序的作用,你需要重新学习一下预排序。

O(n^2)的另一种想法:这时候又有同学说我知道了预排序的作用,但是我用的是单循环法,里面有一个for循环一个while循环,两层循环嵌套嘛所以是n^2。这种想法同样是错的,时间复杂度算的是循环的次数而不是循环的个数,两层循环嵌套不一定就是n^2(以后考虑时间复杂度千万不要有这种想法)。再者如果按你的这种思路,如果用双循环法那不就变成n^3了吗,一个排序方法是不可能有两个时间复杂度的。

O(n^3):这个也肯定是不对的,理由和成因可见上面的推理。

下面说明正确的思路:由于希尔排序加入了预排序,预排序的功能肯定是为了降低时间复杂度,所以希尔排序的时间复杂度肯定是小于O(n^2)的,由于每次gap的不固定所以虽然循环有很多但是会由于gap的每次次数不一样而每次循环的次数不同,所以这个时间复杂度是不好求得的。有人就做了研究发现如下第一个图的结论(不用管他是怎么弄的),可以看出gap取中间值时循环的次数最多,所以经过另一个大佬的研究(不用管是怎么研究出来的)由于gap的不确定性(数组最大长度的倍数)导致希尔排序的时间复杂度为O(n^1.2) 至 O(n^1.3)之间,所以我们一般就取O(n^1.3)

空间复杂度分析:由于没有开辟新的空间也没有开辟新的函数栈帧,所以为O(1)

稳定性:由于前后两个相等的值可能会因为希尔排序分组预排时分到两个不同的组而出现顺序的改变,所以希尔排序不稳定

五。快速排序(Quicksort)递归形式

我们之间C语言的qsort和C++的sort的底层结构都是快速排序,快速排序是由霍尔发现的,它采用递归分治法进行排序。所以上图展现的是霍尔法。

霍尔法(左右指针法)(hoare法)

首先就是先在一个待排序的数组a中取一个a[key]作为基准数,这个key霍尔认为一般取头或者尾元素的下标,然后定义两个下标指向头尾的下标(其实也不是指针),左边的为left,右边的为right。快速排序的每次排序的效果是最终让key左边的数都比a[key]小,key右边的都大于等于a[key]。右下标right先从右边开始遍历,当a[right]小于a[key]时就停下来,没有找到比a[key]小的值就继续遍历,然后左下标left从左边开始遍历,当a[left]大于a[key]时就停下来,找到比a[key]大的值就继续遍历,然后将当前左右下标所对应的值交换,然后就继续上述过程。直到左右下标在某个位置相遇就退出,不再进行快速排序了,这时由于right始终是比left先走的,所以一定是right遇到left,在right去相遇left时,left是不动的,说明相遇时left下标所对应的值是小于a[key],不然早就被换了,所以等到left == right时,再将a[key] 和 a[left]互换,这样比a[key]小的值和不比a[key]小的值就被a[key]隔开了。这样第一趟快速排序就完成了。

这样接着上一次选择的基准数a[key]是不参与下一轮的排序的被a[key]分为两边的数组[begin,key - 1] 和 [key + 1,end]再进行第一趟的操作,选出基准数,建立左右下标,进行交换……不断重复这个过程。这有点递归分治成子问题的思路,当待排序列只有一个元素或者没有元素时(begin >= end)就不需要再排了,这个可以作为返回条件,这个就是我们常说的递归子问题。其实在不断递归的过程你选出的基准数是已经排号序的了,所以是不断有序的过程,当分治到只有一个元素或者没有元素时就说明上面的序列都是升序或者降序的了。

详细步骤请见下图:(keyi就是key)

从第2趟开始如图:

这边注意一下:在进行左右下标交换时只有让右边先走才可以保证最后left == right时,a[left]的值是小于a[key]的,如果先遍历left就会导致left先找了大于a[key]的值但是right还没有遍历到小于a[key]的值就和left相遇了。再不断递归是通过上面的画图可以看出递归展开结构就是树形的结构。这个排序方法的实现原理就是借鉴了树的后序遍历的实现方法。

实现代码如下:(升序)

void swap(int* a, int* b)
{
    int c = *a;
    *a = *b;
    *b = c;
}
void Quickqueue(int* aa, int left, int right)
{
    if (left >= right)//做为递归的返回条件,一定要先判断左右下标的关系
    {
        return;
    }
    int key = left;
    int begin = left;//先将开头的下标存下来,防止left后面移动改变了数值
    int end = right;//先将结尾的下标存下来,防止right后面移动改变了数值
    while (left < right)//循环结束条件
    {
        while (left < right && aa[right] >= aa[key])
        {
            --right;//aa[right] >= aa[key]就继续遍历
        }
        while (left < right && aa[left] <= aa[key])
        {
            ++left;//aa[left] < aa[key]就继续遍历
        }
        swap(&aa[left], &aa[right]);
    }
    swap(&aa[left], &aa[key]);//结束循环后就交换以保证a[key]将数据分割
    key = left;//交换了数据就尽量也将下标也交换一下,当然也可以不交换下标那下面的递归就要将key - 1写成left - 1,key + 1写成left + 1
    Quickqueue(aa, begin, key - 1);
    Quickqueue(aa, key + 1, end);
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", aa[i]);//如此打印只要看最后一组数是不是升序就可以看出这个排序写得对不对
    }
    printf("\n");
}

int main()
{
    int arr[10] = { 3,44,38,5,47,15,36,26,1,2 };
    Quickqueue(arr, 0, 9);
    return 0;
}

值得注意的是这里容易忘记的是判断right和left在遍历时就不能越界,如果待排数据全是大于等于aa[key]的那放着right一直减减就会因为right减到小于0而越界,left也是这个理由。

结果说明我们写的排序没有问题。

挖坑法

快速排序还可以用挖坑法进行实现,挖坑法主要解决了原本霍尔法的为什么排升序一定要右边先走的问题。挖坑法也是要每次先选出一个基准数a[key],然后先将其取出这个时候就会有一个空位,a[key]先用一个数先装着,然后定义左右两个下标left和right,同样是要先让right先走以保证最后排序结束剩的空位填入a[key]使得key左边全部都小于a[key],右边全部都大于等于a[key],当right遍历到a[right]小于a[key]时就填入空位,此时空位就变到a[right]的位置,此时遍历left当a[left]大于a[key]时,就填在a[right]这个空位上。这样也是不断在填空位和挖空位的过程,大于a[key]的就会被弄到前面,小于a[key]的就会由于原本有大于a[key]的空位而被弄到后面,最后一定会剩下一个空位,这个空位就是不小于a[key]和小于a[key]的分界点,再将a[key]填在这里就实现了和霍尔法一样的效果了。由于挖空法也是分治法实现快排就是对比霍尔法实现方式不同罢了,本质是一样的。

实现代码如下:(升序)

void swap(int* a, int* b)
{
    int c = *a;
    *a = *b;
    *b = c;
}
void Quickqueue(int* a, int left, int right)
{
    if (left >= right)
    {
        return;
    }
    int key = left;
    int cap = a[key];//先保留key的值
    int begin = left;
    int end = right;
    while (left < right)
    {
        while (left < right && a[right] > a[key])
//要考虑是否会越界的情况
        {
            right--;
        }
        swap(&a[right], &a[key]);
        key = right;
        while (left < right && a[left] < a[key])
//要考虑是否会越界的情况

        {
            left++;
        }
        swap(&a[left], &a[key]);
//挖完就赋值过去,先赋值过去才会产生一个空位
        key = left;//下标赋值上去,比较好找到那个新的空位在哪里
    }
    a[left] = cap;/
/最后一定会留下一个空位给key
    key = left;
    Quickqueue(a, begin, key - 1);
    Quickqueue(a, key + 1, end);
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", a[i]);
    }
    printf("\n");
}
int main()
{
    int arr[10] = { 6, 1, 2, 7, 9, 3, 4, 5, 10, 8 };
    Quickqueue(arr, 0, 9);
    return 0;
}

事实证明我们的实现方法没有问题!!!

前后指针法

前后指针法,的本质还是在原本霍尔法的基础上进行改良的,前后指针法主要减小了循环的个数,是当前主流的写法。也是先规定一个基准值key,key规定在最左边,定义一个前指针prev指向开头元素key的下标就是0号位置,再定义一个后指针cur,cur的位置比prev靠后一个单位,前后指针法的本质是通过前后两个指针将数据分成小于a[key]的和不小于a[key]的,靠前指针cur来完整遍历整个数组的元素,cur如果遍历到大于a[key]的值就通过prev换到前面,直到cur遍历完全部数值。如图所示,先让cur先走,如果a[cur]的值是小于a[key]的那就先让prev加加,如果此时a[cur] == a[prev]交换就没有意义了,后序直接cur加加,如果此时a[cur] != a[prev]就先交换两个的值再++cur,如果a[cur] >= a[key],就不需要换到前面只要cur++就行。等到cur遍历完数组的所有元素时,跳出循环,此时将a[key]的值和a[prev]的值换一下就确保了a[key]之前的值都小于a[key],a[key] 之后的值都大于等于a[key]了。

实现代码如下:(升序)

Quickqueue2(int* p, int left, int end)
{
    if (left >= end)
    {
        return;
    }
    int key = left;
    int prev = left;
    int cur = left + 1;
    while (cur <= end)
    {
        if (p[cur] < p[key] && p[++prev] != p[cur])//注意互换条件
        {
            swap(&p[prev], &p[cur]);
        }
        cur++;//由于无论大于等于a[key]还是小于a[key]都要++所以就放在外面
    }
    swap(&p[key], &p[prev]);
    key = prev;
    Quickqueue2(p, left, key - 1);
    Quickqueue2(p, key + 1, end);
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", p[i]);
    }
    printf("\n");
}

int main()
{
    int arr[10] = { 3,44,38,5,47,15,36,26,1,2 };
    Quickqueue2(arr, 0, 9);
    return 0;
}

事实证明我们写的没有什么问题。

小结:快速排序作为笔试和面试的重点应该要引起重视。全部的快速排序都要先找基准值,然后通过基准值分割区间,然后考虑递归返回条件的同时边递归边进行快速排序,递归左边完就递归右边,快速排序是在一遍一遍递归的过程中不断接近排序目标的。

时间复杂度分析:由于其他方法都是霍尔法的衍生法,所以时间复杂度都是一样的,以霍尔法由于前面提到了霍尔法的递归本质是树的后序遍历,所有递归的深度就是树的高度,所以全部递归的层数为时间复杂度为O(logN),每层递归分了左边和右边加起来都要遍历一般全部元素,所以每层都要遍历全部元素时间复杂度为O(N),合起来时间复杂度就是O(NlogN);

但是上面算的是递归图为完全二叉树的状况,如果有n个元素的数组原本是升序而要用快速排序排升序,递归图就会是最糟糕的非完全二叉树。那每层递归都会出现一边倒的情况(没有左边,只有右边),那递归的深度就会因为每次只去掉一个元素而变成要进行n次,这样合起来时间复杂度就是O(N^2);

所以快速排序在不做优化的情况下的时间复杂度会在O(NlogN)O(N^2)之间。但是由于最坏情况是特例所以最终的时间复杂度为O(NlogN)

空间复杂度分析:由于快速排序利用了递归,开辟了很多的函数栈帧,又由于同一个函数栈帧是可以重复利用的,所以空间复杂度就是递归的深度就是树的高度为O(logN)

稳定性:由于前后两个相等的值会因为快速排序时后指针从后开始判断并交换而可能改变位置,所以快速排序不稳定

经过上面的分析我们不难发现霍尔法的一个很致命的缺点就是如果原本数组的元素是升序或者接近降序然后还要排升序,如果还是取第一个为基准值,那就会使得每次右边指针先走都没有交换,使得时间复杂度很大。所以为了降低某些情况的时间复杂度就需要优化每次基准值的选取。

优化1:随机数法

将基准值定为每次待排区间内数值的随机值,然后将这个随机值和第一个数交换一下,使得仍然取第一个值为基准值,基准值随机后每次取到第一个数的概率就大大降低了。

int p = rand() % (right - left + 1);//注意取定范围,所以要加1
p = p + left;//使用下标进行访问
swap(&aa[left], &aa[p]);//值换一下就行,下标不用换的

优化2:三数取中

我们还可以用三数取中的方法,一般取头尾和中间的值mid进行比较,取里面第二大的值作为基准值,然后和第一个数交换一下,使得仍然取第一个值为基准值,三数取中后每次取到第一个数的概率就大大降低了。我们也比较常用这个方法。

int getmid(int* q, int left, int right)
{
    int mid = (left + right) / 2;
    if (q[mid] > q[left])//先判断中间和任意两边的关系
    {
        if (q[right] > q[mid])//再将另外一个参与比较
        {
            return mid;
        }
        else if (q[left] > q[right])
        {
            return left;
        }
        else
        {
            return right;
        }
    }
    else//q[mid] <= q[left]
    {
        if (q[left] < q[right])
        {
            return left;
        }
        else if (q[mid] > right)
        {
            return mid;
        }
        else
        {
            return right;
        }
    }
}

int mid = getmid(aa, left, right);
swap(&aa[left], &aa[mid]);

优化3:小区间优化

刚刚上面两个优化是对基准值进行优化,这个则是对于排序方法进行优化。所谓小区间优化就是如果待排序的数组的元素过多,假设有1000000个要排,如果每次都用递归会使得递归开辟的栈帧过多,如果待排的数值对比总数值过少,如果还使用回溯法就会使得时间复杂度和空间复杂度过大,使用如果剩下的数值过少我们就直接选择插入排序就行了。

// 小区间选择走插入,可以减少90%左右的递归
if (right - left + 1 < 10)//一般小于10个就使用插入排序
{
    Insertsort(a + left, right - left + 1);
}

else

快速排序的非递归形式

快速排序的递归改非递归是很有必要的,因为过多数据进行递归会使得递归层数过多而使得栈溢出,会栈溢出说明操作系统的栈太小了。常规的递归改非递归的方法主要有两种:

1。使用自己创建的栈进行修改

2。使用不断迭代的方法

我们今天就使用方法1进行修改。代码实现如下:

void quickqueuenonR(int* a, int left, int right)
{
    ST st;
    STInit(&st);//要使用自己创建的栈要先初始化
    STPush(&st, right);//入两个就要出两个,把区间入进去,根据栈先进后出的规则要先入待排区间的右边
    STPush(&st, left);
    while (!STEmpty(&st))//这里先排左边,所以先入右边
    {
        int begin = STTop(&st);
        STPop(&st);//取完栈顶元素就要先移除才可以取到栈里的下一个元素
        int end = STTop(&st);
        STPop(&st);
        int key = begin;
        int prev = begin;
        int cur = begin + 1;
        while (cur <= end)//每次出栈就进行单趟排序
        {
            if (a[cur] < a[key] && ++prev != cur)
            {
                swap(&a[prev], &a[cur]);
            }
            cur++;
        }
        swap(&a[prev], &a[key]);
        key = prev;//循环入栈和出栈
        //判断如果只有一个值或者没有值都不用入栈了
        if (key + 1 < end)//先入右边再入左边,所以先判断右边
        {
            STPush(&st, end);//入两个就要出两个,把区间入进去,循环出栈
            STPush(&st, key + 1);
        }
        if (key - 1 > begin)//先入右边再入左边
        {
            STPush(&st, key - 1);//入两个就要出两个,把区间入进去,循环出栈
            STPush(&st, begin);
        }//入完右边再入左边,左边出去后再入右边,最后会只剩右边因为左边能进去的都进去并出去了
    }

    for (int i = 0; i < 10; i++)
  {
    printf("%d ", a[i]);
  }
    STDestroy(&st);//记得销毁
}
int main()
{
    int arr[10] = { 6,1,2,7,9,3,4,5,10,8 };
    quickqueuenonR(arr, 0, 9);
}

用C语言写的话需要手搓一个栈,如果选用C++就直接调用操作系统里的。

STPush是入栈函数

STTop是取出栈顶元素的函数

STPop是移除栈顶的元素的函数

STEmpty是判断栈是否为空

STInit初始化栈的函数

STDestroy栈的销毁函数

栈内取的是待排区间,以区间确定待排元素的个数由于区间是左闭右闭的所以如果待排区间只有一个元素或者没有元素时就不需要入栈了(不需要排序了),排列时先将整个区间入栈是为了防止空栈而无法进入下面的入栈循环,,也是可以存储开头区间的值和结尾的值。剩下了很多就是霍尔法及其衍生法单次排序的操作了。

注意:上面的非递归形式还可以用队列进行实现,其中一个因为是两个栈可以实现一个队列嘛,但是主流的方法还是用栈。感兴趣的同学可以自己去试试,实在不懂得怎么实现的请及时私信我。然后C语言手动实现栈的代码我会提供在文章的最后,仅供参考!!!

六。堆排序(Heapsort)

堆的实现的底层是数组,堆排序的原理是将整个数组先弄成堆的形式,然后通过向下调整的算法完成排序为什么不用向上调整算法呢,因为比起向上调整算法,向下调整算法的时间复杂度会比较小。

如果如图的数据要排升序,那如果要直接使用向下调整算法来实现堆排序,那前提就是这个形成的堆的子堆要是大堆或者小堆。由于待排数组的数据是随机的所以我们不能直接用向下调整算法,但是我们可以通过分解子堆的方式。

向下调整法的视图:

既然从头结点用不了(因为从头结点来看,它的左右子堆都不是大堆或者小堆),那我们就从上图的最小子树先调整,再依次向上向左找更多的堆结构。可以发现最小的树结构是6->8这个,那我们向下调整建堆的循环起始位置就是6这个结点,然后通过堆的父亲结点和儿子结点的关系找到子结点的位置进行比较,然后比完一个小堆就让父亲结点向前移动一个单位,因为堆中所有结点的序号都是连续的所有通过以每次父亲结点为头结点的堆都可以得到排序,直到所有的父亲结点都遍历了一遍整个数组就形成大堆或者小堆了。

为什么不从上图的8开始遍历呢,因为8这个结点的下面没有儿子结点,所有从8开始没有意义。

还有一个问题:建堆的方法搞定了,那如果要排升序到底是大堆还是建小堆呢?我的答案是建大堆。如果建小堆,那么最小的元素在堆顶,此时如果取堆顶元素是取到最小的了,那剩下的数就需要重新通过向下调整法重新建小堆使得次小的数来到堆顶,在取堆顶元素,再重新建堆……。这样升序建小堆的方法也不是不行,但是这样一来通过不断的重新建堆使得堆的内部元素的关系被破坏。再者建队的时间复杂度是O(N),不断建堆会使得时间复杂度很大,n个数据就要建n - 1次堆,那时间复杂度会变成O(N^2),那和遍历一遍数组直接排序有什么区别呢,加上还要建堆代码实现难度变大了,更麻烦!!!

升序就建大堆,那要怎么排升序呢?

这边就需要利用一个十分巧妙的方法:大堆建成之后,最大的数在堆顶对吧,那就将最大的数和堆的最后一个数先交换一下,这样最大的数的位置就定下来了,然后位置定下来的数就不参与排序,再从此时交换过后的头结点开始使用向下调整法,这样等调整完后次大的数就会被弄到堆顶,再将这个次大的数和当前的最后一个数交换一下,这时倒数第二个数就排好了……如此做倒数第三大和倒数第四大的数等等也就可以实现排位了,最后当堆中只剩下一个数时,那个数一定是最小的了,升序也就排好了。这个方法的好处就在于不需要每次提取数值就建队,很好的控制就时间复杂度。更多的细节请看下面的代码实现。

代码实现(升序):

void swap(int* a, int* b)
{
    int c = *a;
    *a = *b;
    *b = c;
}
void Adjustdown(int* a, int parent, int sum)
{
    int child = parent * 2 + 1;//通过父亲结点找到孩子结点
    while (child < sum)//退出循环条件
    {
        // 假设法,选出左右孩子中小的那个孩子
        if (child + 1 < sum && a[child + 1] > a[child])//形成大堆交换的是左右孩子中大的值,然而想找到左右孩子那个大的前提是右孩子必须存在所有child + 1 < sum

        {
            child++;//如果右孩子大就选择右孩子
        }
        if (a[parent] < a[child])
        {
            swap(&a[parent], &a[child]);
            parent = child;//父子交换排下一组
            child = parent * 2 + 1;
        }
        else//如果孩子不大于父亲就不需要排序了,已经是大堆了
        {
            break;
        }
    }
}
void Heapsort(int* a, int n)
{
    for (int i = (n - 1 - 1) / 2; i >= 0; i--)//要连减两个1是为了防止父亲结点没有右孩子的情况,通过左孩子也可以访问到父亲结点的
    {
        Adjustdown(a, i, n);//向下调整法,要考虑交换的是父亲结点,所以传的是父亲结点
    }
    int end = n;
    while (end > 0)
    {
        swap(&a[0], &a[end - 1]);//数组交换
        Adjustdown(a, 0, end - 1);//同样的向下调整法调整次大的数
        end--;//开始调整下一个数
    }
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int arr[] = { 5, 3, 9, 6, 2, 4, 7, 1, 8 };
    Heapsort(arr, 9);
    return 0;
}

可以看到我们写的没有什么问题。

时间复杂度分析:由于堆排序分为建堆和向下调整排序法,建堆的时间复杂度就不需要算了,因为建队不属于排序的部分,但是可以告诉你是O(N)。向下调整法的时间复杂度是O(NlogN),因为每个数被换到堆顶都相对于下面的数是比较小的,每次要交换的位置都从堆顶到接近堆的最尾部,最坏的情况就是每次都从堆头交换到堆尾,就是N个数,每个数移动的距离就是堆的高度,堆可以看成二叉树进行计算,又由于每次排序的元素个数是不一样的所以堆的高度也是不一定一样的,但是堆的高度都可以写成logN,N为待排元素总个数,所以堆排序总的时间复杂度就是O(NlogN)

空间复杂度分析:由于没有开辟新的空间也没有开辟新的函数栈帧,所以为O(1)

稳定性:由于前后两个相等的值会因为堆排序而改变位置,相同的值的前面那个会先被换到堆的最后导致两个相同的值的相对位置发生改变,所以堆排序不稳定

那不知道你们有没有想过建小堆一定不行吗?可不可以仿照上面建大堆的方法进行改良呢?

升序建小堆方法(改良版)

建小堆完后再进行向下调整方法的思路可以先建小堆然后的操作和上面建大堆的有点像,堆的孩子之间是比不出大小的,只有堆顶元素的全堆最小的。

第一步:在排完小堆后先取堆顶元素,然后打印出来

第二步:将堆顶元素和最后一个参与向下调整法的元素交换一下,然后最后一个元素不参与向下调整法的重排小堆,这样通过向下调整法,次小的数就会变到最上面。

第三步:不断重复以上两步的操作,等堆中只剩下一个元素时就是最大的值了。这样最后一次取堆顶元素,打印出来,综合以上全部的打印结果,升序就排完了。

这样时间复杂度就和上面建大堆的方法一样了!!!

七。归并排序(递归形式)(Mergesort)

归并排序主要分为归和并两个过程,简单来说就是如果一个待排数组a,先将其分为两半,如果这两半都同为升序或者降序那就符合了归并排序中并这个操作的前提。然后不是要排升序,所以从那两半的开头开始取数,取小的尾插到另一个一个提前开好的空间tmp,不断取两半中小的值,等一半取完后,如果另一半还有数就直接将后面的数尾插到tmp上就可以了,因为剩下的数是有序的而且一定会比没有尾插入tmp数组中之前p内的最后一个元素大的。最后一步为等到左右两半全部都没有元素可以进入时就表示排列完成,这时只要将tmp拷贝到原数组a中就可以了

但是理论是理想的,现实情况可能没有这么好,万一在第一次分成两半时两半都不是有序的,或者只有一半是有序的,这要怎么办呢?

面对不能直接进行并操作的,我们就要先归,不是有序的,说明要进行排序,归并排序的第一步就是进行分两半操作,这样如果数组不是有序的就一直分下去,直到分到左右两半都只有一个元素时就可以认为之间平方的数组分割成两半后左右都是有序的了(只有一个元素肯定是有序的),都有序了才可以进行归并排序,这样两个最小的数组有序后,层层合并往上去使得更大的数组有序,这样等合并到最开头整个数组就有序了。所以递归的子问题也就找到了。返回条件也找到了:当平分左右的数组只有一个元素是就返回

以上图为归并排序最小子问题

下图为排序细节分析图:

所以归并排序的思想就是:将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有 序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。先递归,递归到左右无法递归了再返回上一层栈帧进行排序

代码实现(升序):

#include<stdio.h>
#include<stdlib.h>
void mergesort2(int* aaa, int begin, int end, int* tmp)
{
    if (begin == end)
    {
        return;
//只有一个值就返回
    }
    int mid = (begin + end) / 2;
  
 //[begin, mid] [mid + 1, end]
    mergesort2(aaa, begin, mid, tmp);
    mergesort2(aaa, mid + 1,
end, tmp);//一直分分到只有一个值,这个end就是上一次栈帧中的mid
    //最快当左边和右边都只有一个的时候归并
    int begin1 = begin;
    int end1 = mid;
    int begin2 = mid + 1;
    int end2 = end;
    int j =
begin;//tmp从每次begin位置开始排,注意细节
    while (begin1 <= end1 && begin2 <= end2)//只要有一个不满足就可以跳出循环了,所以用&&
    {
        if (aaa[begin1] <= aaa[begin2])
//比较小的尾插到临时开辟的数组的后面
                                       //为了追求稳定性,相同的数据取前一个,防止排序时相同数字的相对位置被弄乱
        {
            tmp[j++] = aaa[begin1++];
        }
        else
        {
            tmp[j++] = aaa[begin2++];
        }
    }
    while (begin2 <= end2)
    {
        tmp[j++] = aaa[begin2++];
    }
    while (begin1 <= end1)
    {
        tmp[j++] = aaa[begin1++];
    }
    memcpy(aaa +
begin, tmp + begin, sizeof(int) * (end - begin + 1));//每次在临时开辟的
                                                                     //数组都要拷贝到那个打印数组里,拷贝位置要对应

}


void mergesort(int* aa, int n)
{
    int* tmp = (int*)malloc(sizeof(int) * n);
//先排在tmp上等下再复制上去
    if (tmp == NULL)
    {
        perror("malloc fail!");
//正常都要判断一下是否开辟成功
        return -1;
    }
    mergesort2(aa, 0, n - 1, tmp);
//在新创建的数组中排序不需要每次排都新弄一个,弄一个大小足够大的数组每次排时都传进去,下次排序还不用清空。
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", aa[i]);
    }
    free(tmp);
    tmp = NULL;
}

int main()
{
    int a[10] = { 6,1,2,7,9,3,4,5,10,8 };
    Mergesort(a, 10);
}

注意:归并排序是先归再并,等平分的左右部分都无法再分时才开始排序,所以排序的代码要放在递归的后面。

事实说明我们写的代码没有问题。

时间复杂度分析:由上面的合并分解图可知,归并排序作为分治法的一种,其递归展开图都是二叉树的形状,所以递归的深度就是时间复杂度就是树高,n个待排元素就是logN,加上每一大层(如下图所示)进行排序都要遍历全部左右数组的全部元素,每一层的时间复杂度就是N,合起来归并排序的时间复杂度就是O(NlogN)

空间复杂度分析:由于递归开辟的栈的总空间是树结构的高度,所以递归部分的空间复杂度为logN,但是我们又malloc了一个用来大小为元素总个数N的用来排序暂存的数组,其空间复杂度为O(N),所以加起来总空间复杂度就是O(N + logN),但是当N无限大时,N会远大于logN,所以最后归并排序的空间复杂度就是O(N)

稳定性:由于前后两个相等的值不会因为归并排序而改变位置,所以归并排序稳定

 归并排序的非递归形式(排序最难的内容)

由于上述的归并排序有可能会因为待排数据过于多而栈溢出,所以归并排序的非递归形式是很重要的。

既然递归形式的最后还是要将整个数组通过递归形式分为单个数字(视为有序),那非递归形式就先将数组分成单个数,然后通过gap控制每次排序的组合的元素个数,每组是限定两个数组。gap == 1就限定每组的数组的元素为1,就相当于两两之间进行排序,那两两之间排序好了,下一次就将gap乘2,这次就是限定每组的数组的元素为2,两两排序……如此循环,gap不断乘2,不断排序,当gap等于原数组元素的一半时整个数组就被分成有序的两半进行排序了,此时下次gap再乘2等于原数组元素个数时就不需要排序了。由于每次排序的组的元素个数都是2的n次方倍,如果待排数组的总元素个数不是刚好为2的n次方倍,这样依照gap进行分组就还会出现某一个分组空间不存在或者越界的情况,这个我们可以还需要通过调试进行调整。这种方法保证每组的两个数组排序的对象都是有序的,合起来再排成有序,我们还会发现非递归形式的实现其实就是原本递归形式的逆过程

更多的细节请看下面代码实现(升序)

void mergesortnull(int* a, int n)//反着来
{
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail!");
        return;
    }
    int gap = 1;
    while (gap < n)//排序结束条件
    {
        for (int j = 0; j < n; j += 2 * gap)
        {
            int begin1 = j;//先存下j的值
            int end1 = begin1 + gap - 1;//此时尾和头是有联系的
            int begin2 = j + gap;
            int end2 = begin2 + gap - 1;
            if (end2 >= n)//如果end2越界了
            {
                end2 = n - 1;//不让其越界,就begin1不可能会越界
            }
            if (end1 >= n || begin2 >= n)//如果后面这个区间越界了就没有比的必要了
            {
                break;//等到gap大了或者只剩下两组的时候也会归并到
            }
            int i = j;//j的值不能动代表了两两排序的开始
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (a[begin1] <= a[begin2])//比较小的尾插到临时开辟的数组的后面
                {
                    tmp[i++] = a[begin1++];
                }
                else
                {
                    tmp[i++] = a[begin2++];
                }
            }
            while (begin2 <= end2)
            {
                tmp[i++] = a[begin2++];
            }
            while (begin1 <= end1)
            {
                tmp[i++] = a[begin1++];
            }
            memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));//每次在临时开辟的空间排序完都要记得拷贝到原数组的对应位置上,j代表了插入和排序的开始,只有j是不变的所以处理j
        }
        gap = gap * 2;
    }
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int a[10] = { 6,1,2,7,9,3,4,5,10,8 };
    mergesortnull(a, 10);//非递归形式
    return 0;
}

可以看到非递归形式的书写是比较难的,细节是很多的,什么时候用i,什么时候用j都需要我们自己去实践一下才会直到。

控制排序区间的头尾序号位置是一个难点,为什么要这样控制和控制的类型都在上图中了,我们可以通过打印每次通过gap分出的排序区间的范围进行调整,这也是调试技巧之一

所以begin1不会越界等到gap大了或者只剩下两组的时候也会归并到,等的推论也就可以得到证明了

八。计数排序(Countsort)

排序中比较排序大类比较重要,非比较排序大类都是比较小众的排序手段,局限性也比较大,所以不那么重要了。上面的七种排序方式都是属于比较排序,而非比较排序主要分为计数排序,基数排序和桶排序,我们就重点介绍计数排序

思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:

1. 统计数组相同元素出现次数

2. 根据统计的结果将序列回收到原来的序列中

计数就是要统计出数组中每个数的个数,所以需要算出数组元素中最大和最小的数,从而得出数组元素的分部范围,统计元素个数说明需要再malloc一个数组count来统计元素个数,这个数组count最大要开多大呢,这个我们前面算的最大最小值就派上用场了,最大最小值相减再加1得出理论上最大的开辟空间的大小。先将count数组全部元素都设置成0,方便之后计数。然后那个最小的数的个数就映射在了count数组的第一个空间,最大就映射在了最后一个空间,这样对应映射可得,倒数第二小的元素就放在count的第2个空间里……,遍历一遍原数组,如果count对应映射空间的值没有就还0,有多少个这样的值count就对应映射空间就加多少,这样最后对着count数组对应下标有几个就打印几个count实际下标对应原数组的值。举个例子如果待排数组p的数为100 101 101 102 103 109,那100就代表count数组的0号空间,100有一个,count[0]就等于1,101有两个count[1]就等于2,109就映射对应count[9],那如何通过count计数数组的下标返回p的值呢,count中0 相当于100,9相当于109,只要将count[i]中的i加上映射相差的数就可以了,例子中的映射数为100,0 + 100 = 100,9 + 100 = 109,这样就做好了下标还原数据的巧妙作用,最后打印下标加上映射值之后的数就可以了,count[i] 等于几就打印多少个,这里显然就是while循环

为什么要映射呢?因为count中最小值所对应的个数的下标一定是0,但是在原数组中的最小值不一定等于0的。

更多细节请看代码实现:

void Countsort(int* a, int n)
{
    int min = a[0];
    int max = a[0];
    for (int i = 0; i < 10; i++)
    {
        if (a[i] < min)
        {
            min = a[i];
        }
        if (a[i] > max)
        {
            max = a[i];
        }
    }//先选出数组中最大和最小的数,方便确定要开辟另一个计数数组的大小
    int range = max - min + 1;
    int* count = (int*)malloc(sizeof(int) * range);
    if (count == NULL)
    {
        perror("malloc fail");
        return -1;
    }
    memset(count, 0, sizeof(int) * range);//先将数组全部的数都弄成0,方便计数,可以用memset之间设置(主要单位是字节),也可以用循环进行设置
    for (int i = 0; i < 10; i++)
    {
        count[a[i] - min]++;//开始计数,让最小的数对应下标为0
    }
    int j = 0;
    for (int i = 0; i < range; i++)
    {
        while (count[i]--)//循环出值等于0了就不入值了
        {
            a[j++] = i - 1;//用下标返回对应的数字,数值重新录入到原数组上方便打印
        }
    }
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", a[i]);
    }
}
int main()
{
    int arr[10] = { 1, 2, 4, 4, 5, 6, 6, 10, -1, 10 };
    Countsort(arr, 10);
    return 0;
}

事实证明我们的代码没有问题!!!

小结:由于计数排序的步骤比较多大体分为三步:可以一步写完验证一下是否正确再进行下一步。

1。计算并取得数组的最大最小值

2。创建count数组进行映射下标并统计元素出现个数

3。通过count数组的下标返回原数组元素,根据个数打印所对应元素的量

时间复杂度分析:如上面实现代码所示,计数排序内只有两个非嵌套并且步长都一样的循环,所以无需考虑这两个循环哪个的循环次数多,所以时间复杂度为O(N)

空间复杂度分析:如上面实现代码所示,由于只多开辟了一个range大小空间的数组,所以空间复杂度为O(range),可以记为O(N)

稳定性:由于计数排序是非比较排序,数据和数据之间没有交换,所以没有讨论稳定性的必要,所以无稳定性

计数排序的缺点

计数排序主要的缺点就一下两个:

缺点1:数据要比较密集才会比较快一点,占用空间会比较小
缺点2:排序非整型的数据会比较困难,因为计数排序的精髓计数通过count可以实现下标和数值的互换,如果要排的是非整型,不仅映射起来比较困难,通过下标返回数值的过程也比较困难。所以这就是计数排序明明都比之前分析的所以排序快,但是却不被广泛使用的原因。

附录:C语言栈的实现(仅供参考)

//stack.h

#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>

typedef int STDataType;
typedef struct Stack
{
    STDataType* a;
    int top;
    int capacity;
}ST;

void STInit(ST* ps);
void STDestroy(ST* ps);

void STPush(ST* ps, STDataType x);
void STPop(ST* ps);
STDataType STTop(ST* ps);
int STSize(ST* ps);
bool STEmpty(ST* ps);

//stack.c

#include"Stack.h"

void STInit(ST* ps)
{
    assert(ps);

    ps->a = NULL;
    ps->top = 0;
    ps->capacity = 0;
}

void STDestroy(ST* ps)
{
    assert(ps);

    free(ps->a);
    ps->a = NULL;
    ps->top = ps->capacity = 0;
}


void STPush(ST* ps, STDataType x)
{
    assert(ps);
    if (ps->top == ps->capacity)
    {
        int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
        STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity * sizeof(STDataType));
        if (tmp == NULL)
        {
            perror("realloc fail");
            return;
        }

        ps->a = tmp;
        ps->capacity = newcapacity;
    }

    ps->a[ps->top] = x;
    ps->top++;
}

void STPop(ST* ps)
{
    assert(ps);
    assert(!STEmpty(ps));

    ps->top--;
}

STDataType STTop(ST* ps)
{
    assert(ps);
    assert(!STEmpty(ps));

    return ps->a[ps->top - 1];
}

int STSize(ST* ps)
{
    assert(ps);

    return ps->top;
}

bool STEmpty(ST* ps)
{
    assert(ps);

    return ps->top == 0;
}

  • 18
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值