排序算法

2023-05-16

 

转载整理自7-sevens大佬,本文仅供本人学习使用,请勿用作其他用途,如有所需,请联系大佬本人!!!

原文链接合集附在本文底部。


一、排序算法系列目录说明

冒泡排序(Bubble Sort)
插入排序(Insertion Sort)
希尔排序(Shell Sort)
选择排序(Selection Sort)
快速排序(Quick Sort)
归并排序(Merge Sort)
堆排序(Heap Sort)
计数排序(Counting Sort)
桶排序(Bucket Sort)
基数排序(Radix Sort)


二、排序算法简介说明

1. 定义
将一组杂乱无章的数据按一定的规律顺次排列起来。例如:

输入:a1,a2,a3,…,an

输出:a1’,a2’,a3’,…,an’(满足a1′ <= a2′ <= a3′ <= … <= an’排列)

2. 算法性能评估术语言
稳定:如果a原本在b前面,而a=b时,排序之后a仍然在b的前面。
不稳定:如果a原本在b的前面,而a=b时,排序之后a可能出现在b的后面。

内排序:所有排序操作都在内存中完成。
外排序:通常是由于数据太大,不能同时存放在内存中,根据排序过程的需要而在外存与内存之间 数据传输才能进行。

时间复杂度:时间频度,一个算法执行所耗费的时间。算法中通常用数据比较次数与数据移动次数 进行衡量。
空间复杂度:算法执行所需要的内存大小。
 


三、冒泡排序(Bubble Sort)

1. 基本思想

冒泡排序是一种交换排序,核心是冒泡,把数组中最小的那个往上冒,冒的过程就是和他相邻的元素交换。

重复走访要排序的数列,通过两两比较相邻记录的排序码。排序过程中每次从后往前冒一个最小值,且每次能确定一个数在序列中的最终位置。若发生逆序,则交换;有俩种方式进行冒泡,一种是先把小的冒泡到前边去,另一种是把大的元素冒泡到后边。

趣味解释:
有一群泡泡,其中一个泡泡跑到一个泡小妹说,小妹小妹你过来咱俩比比谁大,小妹说哇你好大,于是他跑到了泡小妹前面,又跟前面的一个泡大哥说,大哥,咱俩比比谁大呗。泡大哥看了他一眼他就老实了。这就是内层的for,那个泡泡跟每个人都比一次。

话说那个泡泡刚老实下来,另一个泡泡又开始跟别人比谁大了,这就是外层的for,每个泡泡都会做一次跟其他泡泡比个没完的事。

2. 实现逻辑

比较相邻的元素。如果第一个比第二个大,就交换他们两个。
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
通过两层循环控制:

第一个循环(外循环),负责把需要冒泡的那个数字排除在外;
第二个循环(内循环),负责两两比较交换。


3. 动图演示bubble_sort

4. 性能分析

  • 平均时间复杂度:O(N^2)
  • 最佳时间复杂度:O(N)
  • 最差时间复杂度:O(N^2)
  • 空间复杂度:O(1)
  • 排序方式:In-place
  • 稳定性:稳定

解析说明:
冒泡排序涉及相邻两两数据的比较,故需要嵌套两层 for 循环来控制。
外层循环 n 次,内层最多时循环 n – 1次、最少循环 0 次,平均循环(n-1)/2;
所以循环体内总的比较交换次数为:n*(n-1) / 2 = (n^2-n)/2
按照计算时间复杂度的规则,去掉常数、去掉最高项系数,其复杂度为O(N^2)
最优的空间复杂度为开始元素已排序,则空间复杂度为 0;
最差的空间复杂度为开始元素为逆排序,则空间复杂度为 O(N);
平均的空间复杂度为O(1)

注:

  • n:数据规模
  • k:”桶”的个数
  • In-place:占用常数内存,不占用额外内存
  • Out-place:占用额外内存

5. 代码实现(C++版)

// 冒泡排序
void bubble_sort(int arr[], int len)  
{  
    int i, j;  
    for (i = 0; i < len; i++)  
        for (j = 1; j < len - i; j++)  
            if (arr[j - 1] > arr[j])  
                swap(arr[j - 1], arr[j]);  
}

6. 优化改进

6.1 改进方法①
场景一:
在某次遍历中如果没有数据交换,说明整个数组已经有序。若初始序列就是排序好的,如果用基础的冒泡排序方法,仍然还要比较O(N^2)次,但无交换次数。

改进思路:
通过设置标志位来记录此次遍历有无数据交换,进而可以判断是否要继续循环,设置一个flag标记,当在一趟序列中没有发生交换,则该序列已排序好,但优化后排序的时间复杂度没有发生量级的改变。

改进代码(C++版):

// 冒泡排序改进
void bubble_sort(int arr[], int len)
{
    for (int i = 0; i < len-1; i++){        //比较n-1次
        bool exchange = true;               //冒泡的改进,若在一趟中没有发生逆序,则该序列已有序
        for (int j = len-1; j >i; j--){     //每次从后边冒出一个最小值
            if (arr[j] < arr[j - 1]){       //发生逆序,则交换
                swap(arr[j], arr[j - 1]);
                exchange = false;
            }
        }
        if (exchange){
            return;
        }
    }
}

6.2 改进方法②
场景二:
如果有100个数的数组,仅前面10个无序,后面90个都已排好序且都大于前面10个数字,那么在第一趟遍历后,最后发生交换的位置必定小于10,且这个位置之后的数据必定已经有序了。

改进思路:
记录某次遍历时最后发生数据交换的位置pos,这个位置之后的数据显然已经有序了。因此通过记录最后发生数据交换的位置就可以确定下次循环的范围了。由于pos位置之后的记录均已交换到位,故在进行下一趟排序时只要扫描到pos位置即可。

改进代码(C++版):

// 冒泡排序改进② 
void bubble_sort(int arr[], int len)  
{  
    int j, k;  
    int flag;  
    flag = len;  
    while (flag > 0)  
    {  
        k = flag;  
        flag = 0;  
        for (j = 1; j < k; j++)  
            if (arr[j - 1] > arr[j])  
            {  
                swap(arr[j - 1], arr[j]);  
                flag = j;  
            }  
    }  
}

7. 总结

冒泡排序毕竟是一种效率低下的排序方法,在数据规模很小时,可以采用。数据规模比较大时,建议采用其它排序方法。


四、插入排序(Insertion Sort)

插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。打过扑克牌的应该都会明白(当然,如果你说你打扑克牌摸牌的时候从来不按牌的大小整理牌,那我只能呵呵了)

1. 基本思想

插入排序的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

趣味解释:
è¿éåå¾çæè¿°

插入排序操作类似于摸牌并将其从大到小排列。每次摸到一张牌后,根据其点数插入到确切位置。

如上图:表示的是摸到草花7后进行插入的过程。忽略最右边的草花10,相当于一开始7在最右边,然后逐个与左边的排相比较(当然左边的牌早已排好顺序),将其放置在合适的位置。当摸到草花10后重复上述过程即可。

而实际中,如何将插入牌的这个过程应用到实际排序操作中呢?具体我们以一组数字来说操作说明:
è¿éåå¾çæè¿°

例如我们有一组数字:{5,2,4,6,1,3},我们要将这组数字从小到大进行排列。 我们从第二个数字开始,将其认为是新增加的数字,这样第二个数字只需与其左边的第一个数字比较后排好序;在第三个数字,认为前两个已经排好序的数字为手里整理好的牌,那么只需将第三个数字与前两个数字比较即可;以此类推,直到最后一个数字与前面的所有数字比较结束,插入排序完成。

2. 实现逻辑

① 从第一个元素开始,该元素可以认为已经被排序

② 取出下一个元素,在已经排序的元素序列中从后向前扫描

③ 如果该元素(已排序)大于新元素,将该元素移到下一位置

④ 重复步骤③,直到找到已排序的元素小于或者等于新元素的位置

⑤ 将新元素插入到该位置后

⑥ 重复步骤②~⑤

3. 动图演示

è¿éåå¾çæè¿°

4. 性能分析

  • 平均时间复杂度:O(N^2)
  • 最差时间复杂度:O(N^2)
  • 空间复杂度:O(1)
  • 排序方式:In-place
  • 稳定性:稳定

如果插入排序的目标是把n个元素的序列升序排列,那么采用插入排序存在最好情况和最坏情况:

(1) 最好情况:序列已经是升序排列,在这种情况下,需要进行的比较操作需(n-1)次即可。

(2) 最坏情况:序列是降序排列,那么此时需要进行的比较共有n(n-1)/2次。

  • 插入排序的赋值操作是比较操作的次数减去(n-1)次。平均来说插入排序算法复杂度为O(N^2)。
  • 最优的空间复杂度为开始元素已排序,则空间复杂度为 0;
  • 最差的空间复杂度为开始元素为逆排序,则空间复杂度最坏时为 O(N);
  • 平均的空间复杂度为O(1)
注:
n:数据规模
k:”桶”的个数
In-place:占用常数内存,不占用额外内存
Out-place:占用额外内存

5. 代码实现(C++版)

// 插入排序
void InsertSort(int arr[], int len){
	// 检查数据合法性
	if(arr == NULL || len <= 0){
	    return;
	}
	for(int i = 1; i < len; i++){
	    int tmp = arr[i];
		int j;
		for(j = i-1; j >= 0; j--){
			//如果比tmp大把值往后移动一位
			if(arr[j] > tmp){
			   arr[j+1] = arr[j];
			}
			else{
			   break;
			}
		}
		arr[j+1] = tmp;
	}	
}

6. 算法优化改进

6.1 改进方法①
场景分析:
直接插入排序每次往前插入时,是按顺序依次往前查找,数据量较大时,必然比较耗时,效率低。

改进思路:
在往前找合适的插入位置时采用二分查找的方式,即折半插入。

二分插入排序相对直接插入排序而言:平均性能更快,时间复杂度降至O(NlogN),排序是稳定的,但排序的比较次数与初始序列无关,相比直接插入排序,在速度上有一定提升。逻辑步骤:

  • ① 从第一个元素开始,该元素可以认为已经被排序
  • ② 取出下一个元素,在已经排序的元素序列中二分查找到第一个比它大的数的位置
  • ③将新元素插入到该位置后
  • ④ 重复上述两步

改进代码(C++版):
 

// 插入排序改进:二分插入排序
void BinaryInsertSort(int arr[], int len)   
{   
    int key, left, right, middle;   
    for (int i=1; i<len; i++)   
    {   
        key = a[i];   
        left = 0;   
        right = i-1;   
        while (left<=right)   
        {   
            middle = (left+right)/2;   
            if (a[middle]>key)   
                right = middle-1;   
            else   
				left = middle+1;   
        }   
                   
        for(int j=i-1; j>=left; j--)   
        {   
            a[j+1] = a[j];   
        }   
                   
        a[left] = key;          
    }   
}

6.2 改进方法②

场景分析:
(1) 插入排序对几乎已排好序的数据操作时,效率很高,可以达到线性排序的效率。
(2) 插入排序在每次往前插入时只能将数据移动一位,效率比较低。

改进思路:
先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。

改进思路二的方法实际上就是希尔排序。在这里只给出思路,在后续系列 希尔排序 中再做具体讲解说明。


7. 总结

插入排序不适合对于数据量比较大的排序应用。但是,如果需要排序的数据量很小,例如,量级小于千,那么插入排序还是一个不错的选择。尤其当数据基本有序时,采用插入排序可以明显减少数据交换和数据移动次数,进而提升排序效率。 在STL的sort算法和stdlib的qsort算法中,都将插入排序作为快速排序的补充,用于少量元素的排序。
 


五、希尔排序(Shell Sort)

希尔排序的实质就是分组插入排序,该方法又称递减增量排序算法,因DL.Shell于1959年提出而得名。希尔排序是非稳定的排序算法。

在上一篇《算法:排序算法之插入排序》中优化方案中就有提到过使用希尔排序进行改进。希尔排序是基于插入排序的以下两点性质而提出改进方法的:

插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位

1. 基本思想

先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。

因为直接插入排序在元素基本有序的情况下(接近最好情况),效率是很高的,因此希尔排序在时间效率上比前两种方法有较大提高。

2. 实现逻辑

  • 先取一个小于n的整数d1作为第一个增量,把文件的全部记录分成d1个组。
  • 所有距离为d1的倍数的记录放在同一个组中,在各组内进行直接插入排序。
  • 取第二个增量d2小于d1重复上述的分组和排序,直至所取的增量dt=1(dt小于dt-l小于…小于d2小于d1),即所有记录放在同一组中进行直接插入排序为止。

3. 动图演示
è¿éåå¾çæè¿°

具体我们以一组数字来说操作说明:

è¿éåå¾çæè¿°

假设有一组{9, 1, 2, 5, 7, 4, 8, 6, 3, 5}无需序列。

第一趟排序:
设 gap1 = N / 2 = 5,即相隔距离为 5 的元素组成一组,可以分为 5 组。接下来,按照直接插入排序的方法对每个组进行排序。

第二趟排序:

将上次的 gap 缩小一半,即 gap2 = gap1 / 2 = 2 (取整数)。这样每相隔距离为 2 的元素组成一组,可以分为 2
组。按照直接插入排序的方法对每个组进行排序。

第三趟排序:

再次把 gap 缩小一半,即gap3 = gap2 / 2 = 1。 这样相隔距离为 1
的元素组成一组,即只有一组。按照直接插入排序的方法对每个组进行排序。此时,排序已经结束。

注:需要注意一下的是,图中有两个相等数值的元素5和5。我们可以清楚的看到,在排序过程中,两个元素位置交换了。

4. 性能分析

  • 平均时间复杂度:O(Nlog2N)
  • 最佳时间复杂度:
  • 最差时间复杂度:O(N^2)
  • 空间复杂度:O(1)
  • 稳定性:不稳定
  • 复杂性:较复杂

希尔排序的效率取决于增量值gap的选取,时间复杂度并不是一个定值。

开始时,gap取值较大,子序列中的元素较少,排序速度快,克服了直接插入排序的缺点;其次,gap值逐渐变小后,虽然子序列的元素逐渐变多,但大多元素已基本有序,所以继承了直接插入排序的优点,能以近线性的速度排好序。

最优的空间复杂度为开始元素已排序,则空间复杂度为 0;最差的空间复杂度为开始元素为逆排序,则空间复杂度为 O(N);平均的空间复杂度为O(1)希尔排序并不只是相邻元素的比较,有许多跳跃式的比较,难免会出现相同元素之间的相对位置发生变化。比如上面的例子中希尔排序中相等数据5就交换了位置,所以希尔排序是不稳定的算法。

5. 代码实现(C版本)

// C实现
void shell_sort(int arr[], int len) {
	int gap, i, j;
	int temp;
	for (gap = len >> 1; gap > 0; gap >>= 1)
		for (i = gap; i < len; i++) {
			temp = arr[i];
			for (j = i - gap; j >= 0 && arr[j] > temp; j -= gap)
				arr[j + gap] = arr[j];
			arr[j + gap] = temp;
		}
}

6. 重点说明(步( 摘录自wiki百科)

(6.1) 步长序列
步长的选择是希尔排序的重要部分。只要最终步长为1任何步长序列都可以工作。算法最开始以一定的步长进行排序。然后会继续以一定步长进行排序,最终算法以步长为1进行排序。当步长为1时,算法变为插入排序,这就保证了数据一定会被排序。

作者最初的建议是折半再折半知道最后的步长为1<也就是插入排序>,虽然这样取可以比O(n2)类的算法(插入排序)更好,但这样仍然有减少平均时间和最差时间的余地。可能希尔排序最重要的地方在于当用较小步长排序后,以前用的较大步长仍然是有序的。比如, 如果一个数列以步长5进行了排序然后再以步长3进行排序,那么该数列不仅是以步长3有序,而且是以步长5有序。如果不是这样,那么算法在迭代过程中会打乱以前的顺序,那就不会以如此短的时间完成排序了。

(6.2) 常见步长序列

①步长序列:n/2i
最坏情况复杂度:O(n2)

②步长序列:2k-1
最坏情况复杂度:O(n3/2)

③步长序列:2i3j
最坏情况复杂度:O(nlog2n)

注意:由于显示特殊符号存在问题,步长序列中i、k-1,j等都是右上标符号。

已知的最好步长序列是由Sedgewick提出的(1, 5, 19, 41, 109,…),该序列的项来自9 x 4i – 9 x 2i + 1 和 2i+2 x (2i+2 -3)这两个算式。(注意:公众号里面无法显示特殊符号,两个公式中i,j等都是右上标符号)

7. 总结

希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能,交换不相邻的元素以对数组的局部进行排序,最终用插入排序将局部有序的数组排序。

希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于增量因子序列d的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。目前还没有人给出选取最好的增量因子序列的方法。增量因子序列可以有各种取法,有取奇数的,也有取质数的,但需要注意:增量因子中除1外没有公因子,且最后一个增量因子必须为1。希尔排序方法是一个不稳定的排序方法。


六、选择排序(Selection Sort)

选择排序(Selection sort)是一种简单直观的排序算法。

1. 基本思想

首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

选择排序的思想其实和冒泡排序有点类似,都是在一次排序后把最小的元素放到最前面,或者将最大值放在最后面。但是过程不同,冒泡排序是通过相邻的比较和交换。而选择排序是通过对整体的选择,每一趟从前往后查找出无序区最小值,将最小值交换至无序区最前面的位置。

2. 实现逻辑

  • 第一轮从下标为 1 到下标为 n-1 的元素中选取最小值,若小于第一个数,则交换
  • 第二轮从下标为 2 到下标为 n-1 的元素中选取最小值,若小于第二个数,则交换
  • 依次类推下去……

3. 动图演示

è¿éåå¾çæè¿°

注:红色表示当前最小值,黄色表示已排序序列,绿色表示当前位置。

具体的我们以一组无序数列{20,40,30,10,60,50}为例分解说明,如下图所示:

è¿éåå¾çæè¿°

4. 复杂度分析

 

  • 平均时间复杂度:O(N^2)
  • 最佳时间复杂度:O(N^2)
  • 最差时间复杂度:O(N^2)
  • 空间复杂度:O(1)
  • 排序方式:In-place
  • 稳定性:不稳定

选择排序的交换操作介于和(n-1)次之间。选择排序的比较操作为n(n-1)/2次之间。选择排序的赋值操作介于0和3(n-1)次之间。

比较次数O(n^2),比较次数与关键字的初始状态无关,总的比较次数N = (n-1) + (n-2) +…+ 1 = n x (n-1)/2。交换次数O(n),最好情况是,已经有序,交换0次;最坏情况是,逆序,交换n-1次。

5. 代码实现(C版本):

// 选择排序(C)
void selection_sort(int arr[], int len) {
	int i, j, min, temp;
	for (i = 0; i < len - 1; i++) {
		min = i;
		for (j = i + 1; j < len; j++)
			if (arr[min] > arr[j])
				min = j;
	   	temp = arr[min];
		arr[min] = arr[i];
		arr[i] = temp;
	}
}

6. 优化改进

①二元选择排序
改进思路:
简单选择排序,每趟循环只能确定一个元素排序后的定位。根据之前冒泡排序的经验,我们可以考虑改进为每趟循环确定两个元素(当前趟最大和最小记录)的位置,从而减少排序所需的循环次数。改进后对n个数据进行排序,最多只需进行[n/2]趟循环即可。

②堆排序
堆排序是一种树形选择排序,是对直接选择排序的有效改进。具体的分析我们留到后面讲堆排序时再详细说明。

7. 总结

选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对n个元素的表进行排序总共进行至多n-1次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。


七、快速排序(Quick Sort)

快速排序,又称划分交换排序(partition-exchange sort)

1.基本思想

通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

2. 实现逻辑

快速排序使用分治法(Divide and conquer)策略来把一个序列(list)分为两个子序列(sub-lists)。

  • 从数列中挑出一个元素,称为 “基准”(pivot),
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法一定会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。

3. 动图演示

è¿éåå¾çæè¿°

4. 复杂度

  • 平均时间复杂度:O(NlogN)
  • 最佳时间复杂度:O(NlogN)
  • 最差时间复杂度:O(N^2)
  • 空间复杂度:根据实现方式的不同而不同

5. 代码实现

C版本(递归法):

void swap(int *x, int *y) {
	int t = *x;
	*x = *y;
	*y = t;
}
void quick_sort_recursive(int arr[], int start, int end) {
	if (start >= end)
		return;
	int mid = arr[end];
	int left = start, right = end - 1;
	while (left < right) {
		while (arr[left] < mid && left < right)
			left++;
		while (arr[right] >= mid && left < right)
			right--;
		swap(&arr[left], &arr[right]);
	}
	if (arr[left] >= arr[end])
		swap(&arr[left], &arr[end]);
	else
		left++;
    if (left) {
        quick_sort_recursive(arr, start, left - 1);
    }
    quick_sort_recursive(arr, left + 1, end);
}
void quick_sort(int arr[], int len) {
	quick_sort_recursive(arr, 0, len - 1);
}

6. 优化改进

场景分析:
递归是一种使用相同的方法,通过解决问题的子集以达到解决整个问题的方法,是一种使用有限代码解决“无限”计算的方法。在C/C++语言中递归表现在函数对自身的直接/间接的调用上,在实现上,递归依赖于语言的运行时调用堆栈,使用堆栈来保存每一次递归调用返回时所需要的条件。递归通常具有简洁的编码和清晰的思路,但这种简洁是有代价的。一方面,是函数调用的负担;另一方面,是堆栈占用的负担(堆栈的大小是有限的)。

改进思路:
递归转化为迭代。迭代的思想主要在于,在同一栈帧中不断使用现有数据计算出新的数据,然后使用新的数据来替换原有数据。

改进代码

C版本(迭代法):
 

typedef struct _Range {
	int start, end;
} Range;
Range new_Range(int s, int e) {
	Range r;
	r.start = s;
	r.end = e;
	return r;
}
void swap(int *x, int *y) {
	int t = *x;
	*x = *y;
	*y = t;
}
void quick_sort(int arr[], const int len) {
	if (len <= 0)
		return; //避免len等於負值時宣告堆疊陣列當機
	//r[]模擬堆疊,p為數量,r[p++]為push,r[--p]為pop且取得元素
	Range r[len];
	int p = 0;
	r[p++] = new_Range(0, len - 1);
	while (p) {
		Range range = r[--p];
		if (range.start >= range.end)
			continue;
		int mid = arr[range.end];
		int left = range.start, right = range.end - 1;
		while (left < right) {
			while (arr[left] < mid && left < right)
				left++;
			while (arr[right] >= mid && left < right)
				right--;
			swap(&arr[left], &arr[right]);
		}
		if (arr[left] >= arr[range.end])
			swap(&arr[left], &arr[range.end]);
		else
			left++;
		r[p++] = new_Range(range.start, left - 1);
		r[p++] = new_Range(left + 1, range.end);
	}
}

7. 总结

快速排序在排序算法中具有排序速度快,而且是就地排序等优点,使得在许多编程语言的内部元素排序实现中采用的就是快速排序,很多面试题中也经常遇到。对于其算法的改进,除了刚刚上文中提到的意外,根据实际场景还有诸多改进方法,包括对小序列采用插入排序替代,三平均划分,三分区划分等改进方法(相关的改进方法就不一一说明,有兴趣的读者可上网查阅了解)。


八、归并排序(Merge Sort)

归并排序,是创建在归并操作上的一种有效的排序算法。算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。

1. 基本思想

归并排序是用分治思想,分治模式在每一层递归上有三个步骤:

  • 分解(Divide):将n个元素分成个含n/2个元素的子序列。
  • 解决(Conquer):用合并排序法对两个子序列递归的排序。
  • 合并(Combine):合并两个已排序的子序列已得到排序结果。


2. 实现逻辑

2.1 迭代法

①申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
②设定两个指针,最初位置分别为两个已经排序序列的起始位置
③比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
④重复步骤③直到某一指针到达序列尾
⑤将另一序列剩下的所有元素直接复制到合并序列尾

2.2 递归法

①将序列每相邻两个数字进行归并操作,形成floor(n/2)个序列,排序后每个序列包含两个元素
②将上述序列再次归并,形成floor(n/4)个序列,每个序列包含四个元素
③重复步骤②,直到所有元素排序完毕

3. 动图演示
è¿éåå¾çæè¿°

具体的我们以一组无序数列{14,12,15,13,11,16}为例分解说明,如下图所示:

è¿éåå¾çæè¿°

上图中首先把一个未排序的序列从中间分割成2部分,再把2部分分成4部分,依次分割下去,直到分割成一个一个的数据,再把这些数据两两归并到一起,使之有序,不停的归并,最后成为一个排好序的序列。

4. 复杂度分析

  • 平均时间复杂度:O(nlogn)
  • 最佳时间复杂度:O(n)
  • 最差时间复杂度:O(nlogn)
  • 空间复杂度:O(n)
  • 排序方式:In-place
  • 稳定性:稳定

不管元素在什么情况下都要做这些步骤,所以花销的时间是不变的,所以该算法的最优时间复杂度和最差时间复杂度及平均时间复杂度都是一样的为:O( nlogn )

归并的空间复杂度就是那个临时的数组和递归时压入栈的数据占用的空间:n + logn;所以空间复杂度为: O(n)。

归并排序算法中,归并最后到底都是相邻元素之间的比较交换,并不会发生相同元素的相对位置发生变化,故是稳定性算法。

5. 代码实现

5.1 C版本
迭代法:

// 归并排序(C-迭代版)
int min(int x, int y) {
	return x < y ? x : y;
}
void merge_sort(int arr[], int len) {
	int* a = arr;
	int* b = (int*) malloc(len * sizeof(int));
	int seg, start;
	for (seg = 1; seg < len; seg += seg) {
		for (start = 0; start < len; start += seg + seg) {
			int low = start, mid = min(start + seg, len), high = min(start + seg + seg, len);
			int k = low;
			int start1 = low, end1 = mid;
			int start2 = mid, end2 = high;
			while (start1 < end1 && start2 < end2)
				b[k++] = a[start1] < a[start2] ? a[start1++] : a[start2++];
			while (start1 < end1)
				b[k++] = a[start1++];
			while (start2 < end2)
				b[k++] = a[start2++];
		}
		int* temp = a;
		a = b;
		b = temp;
	}
	if (a != arr) {
		int i;
		for (i = 0; i < len; i++)
			b[i] = a[i];
		b = a;
	}
	free(b);
}

递归法:

// 归并排序(C-递归版)
void merge_sort_recursive(int arr[], int reg[], int start, int end) {
	if (start >= end)
		return;
	int len = end - start, mid = (len >> 1) + start;
	int start1 = start, end1 = mid;
	int start2 = mid + 1, end2 = end;
	merge_sort_recursive(arr, reg, start1, end1);
	merge_sort_recursive(arr, reg, start2, end2);
	int k = start;
	while (start1 <= end1 && start2 <= end2)
		reg[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
	while (start1 <= end1)
		reg[k++] = arr[start1++];
	while (start2 <= end2)
		reg[k++] = arr[start2++];
	for (k = start; k <= end; k++)
		arr[k] = reg[k];
}
void merge_sort(int arr[], const int len) {
	int reg[len];
	merge_sort_recursive(arr, reg, 0, len - 1);
}

6. 优化改进

在规模较小时,合并排序可采用直接插入,避免递归调用;
在写法上,可以在生成辅助数组时,俩头小,中间大,这时不需要再在后边加俩个while循环进行判断,只需一次比完。
为了节省将元素复制到辅助数组作用的时间,可以在递归调用的每个层次交换原始数组与辅助数组的角色。

7. 总结

归并排序和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(n log n)的时间复杂度。代价是需要额外的内存空间。


九、堆排序(Heap Sort)

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

1. 堆的相关概念

堆一般指的是二叉堆,顾名思义,二叉堆是完全二叉树或者近似完全二叉树。

堆的性质

  • 是一棵完全二叉树
  • 每个节点的值都大于或等于其子节点的值,为最大堆;反之为最小堆。

è¿éåå¾çæè¿°

堆的存储

一般用数组来表示堆,下标为 i 的结点的父结点下标为(i-1)/2;其左右子结点分别为 (2i + 1)、(2i + 2)

è¿éåå¾çæè¿°

堆的操作

在堆的数据结构中,堆中的最大值总是位于根节点(在优先队列中使用堆的话堆中的最小值位于根节点)。堆中定义以下几种操作:

  • 最大堆调整(Max_Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
  • 创建最大堆(Build_Max_Heap):将堆所有数据重新排序
  • 堆排序(HeapSort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
     

2. 基本思想

利用大顶堆(小顶堆)堆顶记录的是最大关键字(最小关键字)这一特性,使得每次从无序中选择最大记录(最小记录)变得简单。

将待排序的序列构造成一个最大堆,此时序列的最大值为根节点
依次将根节点与待排序序列的最后一个元素交换
再维护从根节点到该元素的前一个节点为最大堆,如此往复,最终得到一个递增序列


3. 实现逻辑

先将初始的R[0…n-1]建立成最大堆,此时是无序堆,而堆顶是最大元素。
再将堆顶R[0]和无序区的最后一个记录R[n-1]交换,由此得到新的无序区R[0…n-2]和有序区R[n-1],且满足R[0…n-2].keys
≤ R[n-1].key
由于交换后新的根R[1]可能违反堆性质,故应将当前无序区R[1…n-1]调整为堆。然后再次将R[1…n-1]中关键字最大的记录R[1]和该区间的最后一个记录R[n-1]交换,由此得到新的无序区R[1…n-2]和有序区R[n-1…n],且仍满足关系R[1…n-2].keys≤R[n-1…n].keys,同样要将R[1…n-2]调整为堆。
直到无序区只有一个元素为止。


4. 动图演示
è¿éåå¾çæè¿°

堆排序算法的演示。首先,将元素进行重排,以匹配堆的条件。图中排序过程之前简单的绘出了堆树的结构。

è¿éåå¾çæè¿°

分步解析说明:
实现堆排序需要解决两个问题:

  • 如何由一个无序序列建成一个堆?
  • 如何在输出堆顶元素之后,调整剩余元素成为一个新的堆?

假设给定一个组无序数列{100,5,3,11,6,8,7},带着问题,我们对其进行堆排序操作进行分步操作说明。

è¿éåå¾çæè¿°

4.1 创建最大堆

①首先我们将数组我们将数组从上至下按顺序排列,转换成二叉树:一个无序堆。每一个三角关系都是一个堆,上面是父节点,下面两个分叉是子节点,两个子节点俗称左孩子、右孩子;

è¿éåå¾çæè¿°

②转换成无序堆之后,我们要努力让这个无序堆变成最大堆(或是最小堆),即每个堆里都实现父节点的值都大于任何一个子节点的值。

è¿éåå¾çæè¿°

③从最后一个堆开始,即左下角那个没有右孩子的那个堆开始;首先对比左右孩子,由于这个堆没有右孩子,所以只能用左孩子,左孩子的值比父节点的值小所以不需要交换。如果发生交换,要检测子节点是否为其他堆的父节点,如果是,递归进行同样的操作。

④第二次对比红色三角形内的堆,取较大的子节点,右孩子8胜出,和父节点比较,右孩子8大于父节点3,升级做父节点,与3交换位置,3的位置没有子节点,这个堆建成最大堆。


è¿éåå¾çæè¿°

⑤对黄色三角形内堆进行排序,过程和上面一样,最终是右孩子33升为父节点,被交换的右孩子下面也没有子节点,所以直接结束对比。

⑥最顶部绿色的堆,堆顶100比左右孩子都大,所以不用交换,至此最大堆创建完成。

è¿éåå¾çæè¿°

4.2 堆排序(最大堆调整)

①首先将堆顶元素100交换至最底部7的位置,7升至堆顶,100所在的底部位置即为有序区,有序区不参与之后的任何对比。

è¿éåå¾çæè¿°

②在7升至顶部之后,对顶部重新做最大堆调整,左孩子33代替7的位置。

è¿éåå¾çæè¿°

③在7被交换下来后,下面还有子节点,所以需要继续与子节点对比,左孩子11比7大,所以11与7交换位置,交换位置后7下面为有序区,不参与对比,所以本轮结束,无序区再次形成一个最大堆。

è¿éåå¾çæè¿°

④将最大堆堆顶33交换至堆末尾,扩大有序区;

è¿éåå¾çæè¿°

⑤不断建立最大堆,并且扩大有序区,最终全部有序。

è¿éåå¾çæè¿°

5. 复杂度分析

 

  • 平均时间复杂度:O(nlogn)
  • 最佳时间复杂度:O(nlogn)
  • 最差时间复杂度:O(nlogn)
  • 稳定性:不稳定


堆排序其实也是一种选择排序,是一种树形选择排序。只不过直接选择排序中,为了从R[1…n]中选择最大记录,需比较n-1次,然后从R[1…n-2]中选择最大记录需比较n-2次。事实上这n-2次比较中有很多已经在前面的n-1次比较中已经做过,而树形选择排序恰好利用树形的特点保存了部分前面的比较结果,因此可以减少比较次数。对于n个关键字序列,最坏情况下每个节点需比较log2(n)次,因此其最坏情况下时间复杂度为nlogn。堆排序为不稳定排序,不适合记录较少的排序。

6. 代码实现(C版本):
 

#include <stdio.h>
#include <stdlib.h>
void swap(int* a, int* b) {
	int temp = *b;
	*b = *a;
	*a = temp;
}
void max_heapify(int arr[], int start, int end) {
	//建立父節點指標和子節點指標
	int dad = start;
	int son = dad * 2 + 1;
	while (son <= end) { //若子節點指標在範圍內才做比較
		if (son + 1 <= end && arr[son] < arr[son + 1]) //先比較兩個子節點大小,選擇最大的
			son++;
		if (arr[dad] > arr[son]) //如果父節點大於子節點代表調整完畢,直接跳出函數
			return;
		else { //否則交換父子內容再繼續子節點和孫節點比較
			swap(&arr[dad], &arr[son]);
			dad = son;
			son = dad * 2 + 1;
		}
	}
}
void heap_sort(int arr[], int len) {
	int i;
	//初始化,i從最後一個父節點開始調整
	for (i = len / 2 - 1; i >= 0; i--)
		max_heapify(arr, i, len - 1);
	//先將第一個元素和已排好元素前一位做交換,再從新調整,直到排序完畢
	for (i = len - 1; i > 0; i--) {
		swap(&arr[0], &arr[i]);
		max_heapify(arr, 0, i - 1);
	}
}
int main() {
	int arr[] = { 3, 5, 3, 0, 8, 6, 1, 5, 8, 6, 2, 4, 9, 4, 7, 0, 1, 8, 9, 7, 3, 1, 2, 5, 9, 7, 4, 0, 2, 6 };
	int len = (int) sizeof(arr) / sizeof(*arr);
	heap_sort(arr, len);
	int i;
	for (i = 0; i < len; i++)
		printf("%d ", arr[i]);
	printf("\n");
	return 0;
}

7. 总结

堆是一种很好做调整的结构,在算法题里面使用频度很高。常用于想知道最大值或最小值的情况,比如优先级队列,作业调度等场景。

堆排序相看似比较复杂(建堆的过程,堆调整的过程,堆排序等等),需要好好推敲揣摩理清思路。堆排序操作过程中其运行时间主要耗费在建初始堆和调整建新堆时进行的反复“筛选”上。


十、计数排序(Counting Sort)

计数排序(Counting sort)是一种稳定的线性时间排序算法。

1. 基本思想

计数排序使用一个额外的数组C,其中第i个元素是待排序数组A中值等于i的元素的个数。

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),然后进行分配、收集处理:

  • 分配。扫描一遍原始数组,以当前值-minValue作为下标,将该下标的计数器增1。
  • 收集。扫描一遍计数器数组,按顺序把值收集起来。


2. 实现逻辑

  • 找出待排序的数组中最大和最小的元素
  • 统计数组中每个值为i的元素出现的次数,存入数组C的第i项
  • 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
  • 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1


3. 动图演示

è¿éåå¾çæè¿°

举个例子,假设有无序数列nums=[2, 1, 3, 1, 5], 首先扫描一遍获取最小值和最大值,maxValue=5, minValue=1,于是开一个长度为5的计数器数组counter

  • (1) 分配

        统计每个元素出现的频率,得到counter=[2, 1, 1, 0,1],例如counter[0]表示值0+minValue=1出现了2次。

  • (2) 收集

        counter[0]=2表示1出现了两次,那就向原始数组写入两个1,counter[1]=1表示2出现了1次,那就向原始数组写入一个2,依次类推,最终原始数组变为[1,1,2,3,5],排序好了。
 

4. 复杂度分析

  • 平均时间复杂度:O(n + k)
  • 最佳时间复杂度:O(n + k)
  • 最差时间复杂度:O(n + k)
  • 空间复杂度:O(n + k)

当输入的元素是n 个0到k之间的整数时,它的运行时间是 O(n + k)。。在实际工作中,当k=O(n)时,我们一般会采用计数排序,这时的运行时间为O(n)。

计数排序需要两个额外的数组用来对元素进行计数和保存排序的输出结果,所以空间复杂度为O(k+n)。

计数排序的一个重要性质是它是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序是相同的。也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。

计数排序的稳定性很重要的一个原因是:计数排序经常会被用于基数排序算法的一个子过程。我们将在后面文章中介绍,为了使基数排序能够正确运行,计数排序必须是稳定的。

5. 代码实现

C版本:

// 计数排序(C)
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
void print_arr(int *arr, int n) {
	int i;
	printf("%d", arr[0]);
	for (i = 1; i < n; i++)
		printf(" %d", arr[i]);
	printf("\n");
}
void counting_sort(int *ini_arr, int *sorted_arr, int n) {
	int *count_arr = (int *) malloc(sizeof(int) * 100);
	int i, j, k;
	for (k = 0; k < 100; k++)
		count_arr[k] = 0;
	for (i = 0; i < n; i++)
		count_arr[ini_arr[i]]++;
	for (k = 1; k < 100; k++)
		count_arr[k] += count_arr[k - 1];
	for (j = n; j > 0; j--)
		sorted_arr[--count_arr[ini_arr[j - 1]]] = ini_arr[j - 1];
	free(count_arr);
}
int main(int argc, char **argv) {
	int n = 10;
	int i;
	int *arr = (int *) malloc(sizeof(int) * n);
	int *sorted_arr = (int *) malloc(sizeof(int) * n);
	srand(time(0));
	for (i = 0; i < n; i++)
		arr[i] = rand() % 100;
	printf("ini_array: ");
	print_arr(arr, n);
	counting_sort(arr, sorted_arr, n);
	printf("sorted_array: ");
	print_arr(sorted_arr, n);
	free(arr);
	free(sorted_arr);
	return 0;
}

6. 优化改进

6.1 场景分析
举个极端的例子:如果排序的数组有200W个元素,但是这200W个数的值都在1000000-1000100,也就说有100个数,总共重复了200W次,现在要排序,怎么办?

这种情况排序,计数排序应该是首选。但是这时候n的值为200W,如果按原来的算法,k的值10001000,但是此时c中真正用到的地方只有100个,这样对空间造成了极大的浪费。

6.2 改进思路
针对c数组的大小,优化计数排序

6.3 改进代码
 

// 计数排序优化(Java)
// 针对c数组的大小,优化计数排序
public class CountSort{
	public static void main(String []args){
		//排序的数组
		int a[] = {100, 93, 97, 92, 96, 99, 92, 89, 93, 97, 90, 94, 92, 95};
		int b[] = countSort(a);
		for(int i : b){
			System.out.print(i + "  ");
		}
		System.out.println();
	}
	public static int[] countSort(int []a){
		int b[] = new int[a.length];
		int max = a[0], min = a[0];
		for(int i : a){
			if(i > max){
				max = i;
			}
			if(i < min){
				min = i;
			}
		}
		//这里k的大小是要排序的数组中,元素大小的极值差+1
		int k = max - min + 1;
		int c[] = new int[k];
		for(int i = 0; i < a.length; ++i){
			c[a[i]-min] += 1;//优化过的地方,减小了数组c的大小
		}
		for(int i = 1; i < c.length; ++i){
			c[i] = c[i] + c[i-1];
		}
		for(int i = a.length-1; i >= 0; --i){
			b[--c[a[i]-min]] = a[i];//按存取的方式取出c的元素
		}
		return b;
	}
}

7、总结

计数算法只能使用在已知序列中的元素在0-k之间,且要求排序的复杂度在线性效率上。

计数排序和基数排序很类似,都是非比较型排序算法。但是,它们的核心思想是不同的,基数排序主要是按照进制位对整数进行依次排序,而计数排序主要侧重于对有限范围内对象的统计。基数排序可以采用计数排序来实现。


十一、桶排序(BucketSort)

桶排序(Bucket sort)或所谓的箱排序,是一个排序算法,工作的原理是将数组分到有限数量的桶里。每个桶再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序),最后依次把各个桶中的记录列出来记得到有序序列。桶排序是鸽巢排序的一种归纳结果。当要被排序的数组内的数值是均匀分配的时候,桶排序使用线性时间(Θ(n))。但桶排序并不是比较排序,他不受到O(n log n)下限的影响。

1. 基本思想

桶排序的思想近乎彻底的分治思想。

桶排序假设待排序的一组数均匀独立的分布在一个范围中,并将这一范围划分成几个子范围(桶)。

然后基于某种映射函数f ,将待排序列的关键字 k 映射到第i个桶中 (即桶数组B 的下标i) ,那么该关键字k 就作为 B[i]中的元素 (每个桶B[i]都是一组大小为N/M 的序列 )。

接着将各个桶中的数据有序的合并起来 : 对每个桶B[i] 中的所有元素进行比较排序 (可以使用快排)。然后依次枚举输出 B[0]….B[M] 中的全部内容即是一个有序序列。

补充: 映射函数一般是 f = array[i] / k; k^2 = n; n是所有元素个数

为了使桶排序更加高效,我们需要做到这两点:

  1. 在额外空间充足的情况下,尽量增大桶的数量
  2. 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中

同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

2. 实现逻辑

  • 设置一个定量的数组当作空桶子。
  • 寻访序列,并且把项目一个一个放到对应的桶子去。
  • 对每个不是空的桶子进行排序。
  • 从不是空的桶子里把项目再放回原来的序列中。

3. 动图演示

è¿éåå¾çæè¿°

分步骤图示说明:设有数组 array = [63, 157, 189, 51, 101, 47, 141, 121, 157, 156, 194, 117, 98, 139, 67, 133, 181, 13, 28, 109],对其进行桶排序:

è¿éåå¾çæè¿°

4. 复杂度分析

  • 平均时间复杂度:O(n + k)
  • 最佳时间复杂度:O(n + k)
  • 最差时间复杂度:O(n ^ 2)
  • 空间复杂度:O(n * k)
  • 稳定性:稳定

桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。

5. 代码实现(C实现)

假设数据分布在[0,100)之间,每个桶内部用链表表示,在数据入桶的同时插入排序。然后把各个桶中的数据合并。

#include<iterator>
#include<iostream>
#include<vector>
using namespace std;
const int BUCKET_NUM = 10;
struct ListNode{
	explicit ListNode(int i=0):mData(i),mNext(NULL){}
	ListNode* mNext;
	int mData;
};
ListNode* insert(ListNode* head,int val){
	ListNode dummyNode;
	ListNode *newNode = new ListNode(val);
	ListNode *pre,*curr;
	dummyNode.mNext = head;
	pre = &dummyNode;
	curr = head;
	while(NULL!=curr && curr->mData<=val){
		pre = curr;
		curr = curr->mNext;
	}
	newNode->mNext = curr;
	pre->mNext = newNode;
	return dummyNode.mNext;
}
ListNode* Merge(ListNode *head1,ListNode *head2){
	ListNode dummyNode;
	ListNode *dummy = &dummyNode;
	while(NULL!=head1 && NULL!=head2){
		if(head1->mData <= head2->mData){
			dummy->mNext = head1;
			head1 = head1->mNext;
		}else{
			dummy->mNext = head2;
			head2 = head2->mNext;
		}
		dummy = dummy->mNext;
	}
	if(NULL!=head1) dummy->mNext = head1;
	if(NULL!=head2) dummy->mNext = head2;
	
	return dummyNode.mNext;
}
void BucketSort(int n,int arr[]){
	vector<ListNode*> buckets(BUCKET_NUM,(ListNode*)(0));
	for(int i=0;i<n;++i){
		int index = arr[i]/BUCKET_NUM;
		ListNode *head = buckets.at(index);
		buckets.at(index) = insert(head,arr[i]);
	}
	ListNode *head = buckets.at(0);
	for(int i=1;i<BUCKET_NUM;++i){
		head = Merge(head,buckets.at(i));
	}
	for(int i=0;i<n;++i){
		arr[i] = head->mData;
		head = head->mNext;
	}
}

6. 总结

桶排序是计数排序的变种,它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。把计数排序中相邻的m个”小桶”放到一个”大桶”中,在分完桶后,对每个桶进行排序(一般用快排),然后合并成最后的结果。

算法思想和散列中的开散列法差不多,当冲突时放入同一个桶中;可应用于数据量分布比较均匀,或比较侧重于区间数量时。

桶排序最关键的建桶,如果桶设计得不好的话桶排序是几乎没有作用的。通常情况下,上下界有两种取法,第一种是取一个10n或者是2n的数,方便实现。另一种是取数列的最大值和最小值然后均分作桶.


十二、基数排序(RadixSort)

基数排序(Radix sort)是一种非比较型整数排序算法。

1. 基本思想

原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。基数排序的方式可以采用LSD(Least significant digital)或MSD(Most significant digital),LSD的排序方式由键值的最右边开始,而MSD则相反,由键值的最左边开始。

  • MSD:先从高位开始进行排序,在每个关键字上,可采用计数排序
  • LSD:先从低位开始进行排序,在每个关键字上,可采用桶排序

2. 实现逻辑

  • 将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。
  • 从最低位开始,依次进行一次排序。
  • 这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。

3. 动图演示

è¿éåå¾çæè¿°

分步图示说明:设有数组 array = {53, 3, 542, 748, 14, 214, 154, 63, 616},对其进行基数排序:

è¿éåå¾çæè¿°

在上图中,首先将所有待比较数字统一为统一位数长度,接着从最低位开始,依次进行排序。

  • 按照个位数进行排序。
  • 按照十位数进行排序。
  • 按照百位数进行排序。

排序后,数列就变成了一个有序序列。

4. 复杂度分析

 

  • 时间复杂度:O(k*N)
  • 空间复杂度:O(k + N)
  • 稳定性:稳定

设待排序的数组R[1…n],数组中最大的数是d位数,基数为r(如基数为10,即10进制,最大有10种可能,即最多需要10个桶来映射数组元素)。

处理一位数,需要将数组元素映射到r个桶中,映射完成后还需要收集,相当于遍历数组一遍,最多元素数为n,则时间复杂度为O(n+r)。所以,总的时间复杂度为O(d*(n+r))。

基数排序过程中,用到一个计数器数组,长度为r,还用到一个rn的二位数组来做为桶,所以空间复杂度为O(rn)。

基数排序基于分别排序,分别收集,所以是稳定的。
 

5. 代码实现(C++版)

int maxbit(int data[], int n) //辅助函数,求数据的最大位数
{
    int maxData = data[0];		///< 最大数
    /// 先求出最大数,再求其位数,这样有原先依次每个数判断其位数,稍微优化点。
    for (int i = 1; i < n; ++i)
    {
        if (maxData < data[i])
            maxData = data[i];
    }
    int d = 1;
    int p = 10;
    while (maxData >= p)
    {
        //p *= 10; // Maybe overflow
        maxData /= 10;
        ++d;
    }
    return d;
/*    int d = 1; //保存最大的位数
    int p = 10;
    for(int i = 0; i < n; ++i)
    {
        while(data[i] >= p)
        {
            p *= 10;
            ++d;
        }
    }
    return d;*/
}
void radixsort(int data[], int n) //基数排序
{
    int d = maxbit(data, n);
    int *tmp = new int[n];
    int *count = new int[10]; //计数器
    int i, j, k;
    int radix = 1;
    for(i = 1; i <= d; i++) //进行d次排序
    {
        for(j = 0; j < 10; j++)
            count[j] = 0; //每次分配前清空计数器
        for(j = 0; j < n; j++)
        {
            k = (data[j] / radix) % 10; //统计每个桶中的记录数
            count[k]++;
        }
        for(j = 1; j < 10; j++)
            count[j] = count[j - 1] + count[j]; //将tmp中的位置依次分配给每个桶
        for(j = n - 1; j >= 0; j--) //将所有桶中记录依次收集到tmp中
        {
            k = (data[j] / radix) % 10;
            tmp[count[k] - 1] = data[j];
            count[k]--;
        }
        for(j = 0; j < n; j++) //将临时数组的内容复制到data中
            data[j] = tmp[j];
        radix = radix * 10;
    }
    delete []tmp;
    delete []count;
}

6. 总结

基数排序与计数排序、桶排序这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

基数排序不是直接根据元素整体的大小进行元素比较,而是将原始列表元素分成多个部分,对每一部分按一定的规则进行排序,进而形成最终的有序列表。
 


原文链接:

算法:排序算法之冒泡排序:https://blog.csdn.net/developer1024/article/details/79736506

算法:排序算法之插入排序:https://blog.csdn.net/developer1024/article/details/79751278

算法:排序算法之希尔排序:https://blog.csdn.net/developer1024/article/details/79767334

算法:排序算法之选择排序:https://blog.csdn.net/developer1024/article/details/79769466

算法:排序算法之快速排序:https://blog.csdn.net/developer1024/article/details/79769587

算法:排序算法之归并排序:https://blog.csdn.net/developer1024/article/details/79769708

算法:排序算法之堆排序:https://blog.csdn.net/developer1024/article/details/79770040

算法:排序算法之计数排序:https://blog.csdn.net/developer1024/article/details/79770154

算法:排序算法之桶排序:https://blog.csdn.net/developer1024/article/details/79770240

算法:排序算法之基数排序:https://blog.csdn.net/developer1024/article/details/79770321

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

排序算法 的相关文章

  • 数据结构<1>时间复杂度详解和leetcode例题

    文章目录 什么是时间复杂度和空间复杂度 前言 算法效率 时间复杂度的计算 空间复杂度的计算 oj练习 什么是时间复杂度和空间复杂度 前言 算法效率 算法效率分析分为两种 第一种是时间效率 第二种是空间效率 时间效率被称为时间复杂度 而空间效
  • 剑指 Offer 53 - I. 在排序数组中查找数字 I

    剑指 Offer 53 I 在排序数组中查找数字 I 题目 题目链接 具体代码 题目 题目链接 https leetcode cn com problems zai pai xu shu zu zhong cha zhao shu zi l
  • 七大排序算法

    目录 直接插入排序 希尔排序 直接选择排序 堆排序 冒泡排序 快速排序 快速排序优化 非递归实现快速排序 归并排序 非递归的归并排序 排序 所谓排序 就是使一串记录 按照其中的某个或某些关键字的大小 递增或递减的排列起来的操作 常见的排序算
  • 桶排序 (详细图解)

    一 桶排序 桶排序 Bucket sort 或所谓的箱排序 是一个排序算法 工作的原理是将数组分到有限数量的桶里 每个桶再个别排序 有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序 最后依次把各个桶中的记录列出来记得到有序序列
  • 堆排序(Heapsort)-- 高级排序算法

    1 堆排序 Heapsort 堆排序 Heapsort 是指利用堆这种数据结构所设计的一种排序算法 二叉堆本质上是一种完全二叉树 它分为两个类型 最大堆和最小堆 最大堆任何一个父节点的值 都大于等于它左右孩子节点的值 最小堆任何一个父节点的
  • 2. 合并两个有序数组

    2 合并两个有序数组 题目描述 解题思路 代码 题目描述 给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2 另有两个整数 m 和 n 分别表示 nums1 和 nums2 中的元素数目 请你 合并 nums2 到 nums
  • 数组实例解析3(杨辉三角)

    根据用户输入的行数n输出对应行数的杨辉三角 具体如下 1 1 1 1 2 1 1 3 3 1 1 4 6 4 1 1 5 10 10 5 1 public class ArrayTraingleTest public static void
  • (SUB)选择排序时间、空间复杂度

    基本思想 将一组数据分为两部分 前面是已排序部分 后面是未排序部分 初始状态可认为位置 0 为已排序部分 数组下标从0开始 其余为未排序部分 每一次都从未排序部分选择一个最小元素放在已排序部分的末尾 然后已排序部分增加一个元素 未排序部分减
  • 七大经典排序算法总结【详解】

    排序算法的分类 插入排序 选择排序 交换排序 归并排序 具体分类如图所示 这七种排序算法在我们生活中应用非常广泛 所用的场景各有不同 他的时间复杂度和空间复杂度也是不同的 一 插入排序 初始数据越接近有序 时间效率越高 1 直接插入排序 直
  • CH1-绪论

    文章目录 算法时间复杂度的计算 一 冒泡排序简介 从小到大排序 算法时间复杂度的计算 我们一般只关心随着问题规模n趋于无穷时 函数中对函数结果影响最大的项 比如说 T n 3n 3 当n非常大的时候 常数3和n的系数3对函数结果的影响就很小
  • 快速排序(C语言简单实现)

    快速排序 C语言简单实现 快速排序 Quick Sort 是冒泡排序的升级版 基本思想 通过一趟排序将待排记录分割成独立的两部分 其中一部分记录的关键字均比另一部分记录的关键字小 则可分别对这两部分记录继续进行排序 以达到整个序列有序的目的
  • 计数排序基础思路

    所谓计数排序 也可以称为散列表 也是一种简单的哈希桶 今天 小编带大家来了解计数排序的基本思路 一 基本思路 以升序为例 计数排序通俗来讲 分为三个步骤 首先制作包含所有要排序的数的桶 相同的数制作一个桶即可 以2 3 6 1 4 1 2
  • Java往字符串数组中追加一个数据

    public class Test public static void main String args 原字符串数组 String arr 原字符串数据1 原字符串数据2 执行数据添加 arr insert arr 需要追加的字符串数据
  • 8-13外部排序-败者树

    败者树是树形选择排序的一种变体 可视为一棵完全二叉树 通过败者树 可以在k个归并段中选出最小关键字所需要的关键字对比次数更少 绿色为叶子结点 存放初始数据 黑色为失败结点 蓝色为胜出结点 一 基本过程 以下按从小到大的方式构建 1 从8个归
  • 四种排序:选择,插入,冒泡,快速排序原理及其对应的时间、空间复杂度解析

    四种排序 选择 插入 冒泡 快速排序原理及其对应的时间空间复杂度 首先 在了解四种排序之前 让我们来了解一下什么是时间复杂度和空间复杂度 时间复杂度 算法的时间复杂度是一个函数 它定性描述该算法的运行时间 记做T n 直白的来说 就是指运行
  • 插入排序超详解释,一看就懂

    目录 一 插入排序的相关概念 1 基本思想 2 基本操作 有序插入 二 插入排序的种类 三 直接插入排序 1 直接插入排序的过程 顺序查找法查找插入位置 2 使用 哨兵 直接插入排序 四 直接插入排序算法描述 五 折半插入排序 1 查找插入
  • 基数排序代码实现

    详情请看排序总结 传送门 https blog csdn net m0 52711790 article details 121914543 基数排序的知识点我就不贴出来 相信都能搜到对应概念解释 下面就直接上代码 代码解释其实也很清晰了
  • DS八大排序之冒泡排序和快速排序

    前言 前两期我们已经对 插入排序 直接插入排序和希尔排序 和 选择排序 直接选择排序和堆排序 进行了详细的介绍 这一期我们再来详细介绍一组排序 交换排序 即耳熟能详的冒泡排序和赫赫有名的快速排序 本期内容介绍 冒泡排序 快速排序 Hoare
  • 冒泡排序/选择排序/插入排序/快速排序/归并排序/桶排序/堆排序/希尔排序/计数排序/基数排序/二分查找/广度优先搜索/深度优先搜索

    排序算法 冒泡排序 Bubble Sort 通过重复地比较相邻的元素并交换它们 使得最大 或最小 的元素逐渐移动到列表的一端 从而实现排序 选择排序 Selection Sort 在未排序的部分中 选择最小 或最大 的元素 并将其放置在已排
  • 数据结构——排序

    前言 哈喽小伙伴们好久不见 也是顺利的考完试迎来了寒假 众所周知 不怕同学是学霸 就怕学霸放寒假 假期身为弯道超车的最佳时间 我们定然是不能懒散的度过 今天我们就一起来学习数据结构初阶的终章 七大排序 本文所有的排序演示都为升序排序 目录

随机推荐