常见排序算法

 

文章目录

  • 一、什么是排序
    • 稳定性
    • 应用场景
  • 二、冒泡排序
    • 解析冒泡排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
    • 冒泡排序优化
  • 三、选择排序
    • 解析选择排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 四、插入排序
    • 解析插入排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 五、希尔排序
    • 解析希尔排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 六、归并排序
    • 解析归并排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 七、快速排序
    • 解析快速排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 八、堆排序
    • 解析堆排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 九、计数排序
    • 解析计数排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 十、桶排序
    • 解析桶排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现
  • 十一、基数排序
    • 解析基数排序
    • 稳定性、比较次数、移动次数、时间复杂度、空间复杂度
    • 代码实现

 
 

一、什么是排序

  排序就是将输入的数字按照从小到大的顺序进行排序。这里我们用柱形来表示数字,数字越大,柱形就越高。
常见排序算法_第1张图片
  假设现在有如上图所示的输入数据,那么我们的目标就是将它们像下图一样,按从小到大的顺序从左边开始依次排列。
常见排序算法_第2张图片
  如果只有8个数据,手动排序也能轻松完成,但如果有10000个、1000000个数据,排序就不那么容易了。这时,使用高效率的排序算法便是解决问题的关键。这就涉及到排序算法设计与使用了。
  排序算法可以分为内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。常见的内部排序算法有:插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序、桶排序等。
常见排序算法_第3张图片
常见排序算法_第4张图片
 
 

稳定性

  假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,A1=A2,且A1在A2之前,而在排序后的序列中,A1仍在A2之前,则称这种排序算法是稳定的;否则称为不稳定的。
  稳定也可以理解为一切皆在掌握中,元素的位置处在你在控制中。而不稳定算法有时就有点碰运气,随机的成分。当两元素相等时它们的位置在排序后可能仍然相同,但也可能不同,是未可知的。

判断方法:
  对于不稳定的排序算法,只要举出一个实例,即可说明它的不稳定性;而对于稳定的排序算法,必须对算法进行分析从而得到稳定的特性。需要注意的是,排序算法是否为稳定的是由具体算法决定的,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。

稳定的意义:
  1、如果只是简单的进行数字的排序,那么稳定性将毫无意义。
  2、如果排序的内容仅仅是一个复杂对象的某一个数字属性,那么稳定性依旧将毫无意义(所谓的交换操作的开销已经算在算法的开销内了,如果嫌弃这种开销,不如换算法好了?)
  3、如果要排序的内容是一个复杂对象的多个数字属性,但是其原本的初始顺序毫无意义,那么稳定性依旧将毫无意义。
  4、除非要排序的内容是一个复杂对象的多个数字属性,且其原本的初始顺序存在意义,那么我们需要在二次排序的基础上保持原有排序的意义,才需要使用到稳定性的算法,例如要排序的内容是一组原本按照价格高低排序的对象,如今需要按照销量高低排序,使用稳定性算法,可以使得想同销量的对象依旧保持着价格高低的排序展现,只有销量不同的才会重新排序。(当然,如果需求不需要保持初始的排序意义,那么使用稳定性算法依旧将毫无意义)。
 
 

应用场景

(1)若n较小(如n≤50),可采用直接插入或直接选择排序
 当记录规模较小时,直接插入排序较好;否则因为直接选择移动的记录数少于直接插人,应选直接选择排序为宜。
(2)若文件初始状态基本有序(指正序),则应选用直接插人、冒泡或随机的快速排序为宜;
(3)若n较大,则应采用时间复杂度为O(nlgn)的排序方法:快速排序、堆排序或归并排序

快速排序是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
 堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。这两种排序都是不稳定的。
 若要求排序稳定,则可选用归并排序。但前面介绍的从单个记录起进行两两归并的排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序子序列,然后再两两归并之。因为直接插入排序是稳定 的,所以改进后的归并排序仍是稳定的。

 
 

二、冒泡排序

解析冒泡排序

  冒泡排序就是重复“从序列右边开始比较相邻两个数字的大小,再根据结果交换两个数字的位置”这一操作的算法。在这个过程中,数字会像泡泡一样,慢慢从左往右“浮”到序列的顶端,所以这个算法才被称为“冒泡排序”。
常见排序算法_第5张图片

  在序列的最右边放置一个天平,比较天平两边的数字。如果右边的数字较小,就交换这两个数字的位置。

常见排序算法_第6张图片

  由于13>4,所以交换这两个数字。
常见排序算法_第7张图片
  完成后,天平往左移动一个位置,比较两个数字的大小。此时5<13,所以需要交换。

  继续将天平往左移动一个位置并比较数字。重复同样的操作直到天平到达序列最右端。
常见排序算法_第8张图片

  不断对数字进行交换,天平最终到达了最右边。通过这一系列操作,序列中最大的数字就会移动到最右端。
常见排序算法_第9张图片
  最右边的数字已经归位。
常见排序算法_第10张图片
  将天平移回最左边,然后重复之前的操作,直到天平到达最右边第2个位置为止。
常见排序算法_第11张图片
  当天平到达最右端第2个位置,序列中第2大的数字也就到达了指定位置。
常见排序算法_第12张图片
  将天平移回最左端,重复同样的操作直到所有数字都归位为止。

常见排序算法_第13张图片

总结:
  在冒泡排序中,第 1 轮需要比较 n-1 次,第 2 轮需要比较 n-2 次…第 n-1 轮需要比较 1 次。因此,总的比较次数为 (n-1) + (n-2) + … + 1 ≈ n2/2。这个比较次数恒定为该数值,和输入数据的排列顺序无关。
  不过,交换数字的次数和输入数据的排列顺序有关。假设出现某种极端情况,如输入数据正好以小到大的顺序排列,那么便不需要任何交换操作;反过来,输入数据需要以大到小的顺序排列,那么每次比较数字后便都要进行交换。因此,冒泡排序的时间复杂度O(n2)。
 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(1),只需要一个辅助空间。

时间复杂度: O(n2)
  若数组的初始状态是正序的,一趟扫描即可完成排序。所需的关键字比较次数 C 和记录移动次数 M 均达到最小值:Cmax = n - 1,Mmin = 0。所以,冒泡排序最好的时间复杂度为O(n)
  若数组是反序的,需要进行 n-1 趟排序。每趟排序要进行 n-i 次关键字的比较( 1 <= i <= n-1 ),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:Cmax = n(n-1)/2 = O(n2),Mmax = 3n(n-1)/2 = O(n2)。冒泡排序的最坏时间复杂度为 O(n2)
  综上,因此冒泡排序总的平均时间复杂度为 O(n2)

比较次数、移动次数: 若初始数组为“正序”序列,则只需进行一趟排序,在排序过程中进行 n-1 次关键字间的比较,且不移动记录;反之,若初始数组为“逆序”序列,则需进行 n-1 趟排序,需进行 (n-1) + (n-2) + … + 3 + 2 = n(n-1)/2 次比较并作等数量级的记录移动

稳定性: 冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,我想你是不会再无聊地把他们俩交换一下的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改 变,所以冒泡排序是一种稳定排序算法

 
 

代码实现

void bubble_sort(int arr[], int len) {
        for (int i = 0; i < len - 1; i++)
                for (int j = 0; j < len - 1 - i; j++)
                        if (arr[j] > arr[j + 1]) {
                                int temp;
								temp = arr[j];
                                arr[j] = arr[j + 1];
                                arr[j + 1] = temp;
                        }
}

 
 

冒泡排序优化

冒泡排序的问题: 冒泡排序算法不管你是否有序,就直接循环比较就是啦!

  比如一个数组为:[ 1,2,3,4,5 ],一个有序的数组,根本不需要排序,仍然进行双层循环将数据遍历一遍,这是完全没有必要的,浪费计算机资源。
  我们可以设定一个临时遍历来标记该数组是否有序,如果已经有序了就不需要排序了,直接结束即可。

void bubble_sort(int arr[], int len) {
    for (int i = 0; i < len - 1; i++){
    	int flag = 1;
        for (int j = 0; j < len - 1 - i; j++)
            if (arr[j] > arr[j + 1]) {
                int temp;
				temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                flag = 0;
            }
        if(flag)
        	break;
	}
}

 
 

三、选择排序

解析选择排序

  选择排序就是重复 “从待排序的数据中寻找最小值,将其与序列最左边的数字进行交换” 这一操作的算法。在序列中寻找最小值时使用的是线性查找。
  选择排序的思路是这样的(以从小到大排序为例):首先,找到数组中最小的元素,拎出来,将它和数组的第一个元素交换位置,第二步,在剩下的元素中继续寻找最小的元素,拎出来,和数组的第二个元素交换位置,如此循环,直到整个数组排序完成。
常见排序算法_第14张图片
常见排序算法_第15张图片
  使用线性查找在数据中寻找最小值,于是我们找到了最小值 1 (从第一个记录开始,逐个比较记录的关键字,找到最小值)。
常见排序算法_第16张图片
  将最小值 1 与序列最左边的 7 进行交换,最小值 1 归位。不过,如果最小值已经在最左端,就不需要任何操作了!
常见排序算法_第17张图片

  在余下的数据中继续寻找最小值。这次找到的是4。
常见排序算法_第18张图片
  将数字4与左边第2个数字 13 进行交换,余下数字中最小值 4 归位。
常见排序算法_第19张图片
常见排序算法_第20张图片
  排序完成!
总结:
  选择排序使用了线性查找来寻找最小值,因此在第1轮中需要比较 n-1 个数字,第2轮需要比较 n-2 个数字……到第 n-1 轮的时候就只需要比较 1 个数字。因此,总的比较次数与冒泡排序的相同,都是 (n-1)+(n-2)+…+1 ≈ n2/2次。
  每轮中交换数字的次数最多为 1 次。如果输入数据就是按从小到大的顺序排列的,便不需要进行任何交换。选择排序的时间复杂度也和冒泡排序的一样,都为O(n2)。
 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(1),只需要一个辅助空间即可。

时间复杂度: 无论数据如何都是 O(n2)

比较次数、移动次数: 选择排序的交换操作介于 0 和 (n - 1)次之间。选择排序的比较操作为 n (n - 1) / 2 次之间。选择排序的赋值操作介于 0 和 3 (n - 1) 次之间。比较次数O(n2,比较次数与关键字的初始状态无关,总的比较次数N=(n-1)+(n-2)+…+1=n*(n-1)/2。交换次数O(n),最好情况是,已经有序,交换0次最坏情况交换n-1次,逆序交换n/2次。交换次数比冒泡排序少多了,由于交换所需CPU时间比比较所需的CPU时间多,n值较小时,选择排序比冒泡排序快。

稳定性: 选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n-1个元素,第n个 元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果当前元素比一个元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么 交换后稳定性就被破坏了。比较拗口,举个例子,序列5 8 5 2 9, 我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了,所以选择排序不是一个稳定的排序算法
 
 

代码实现

void selection_sort(int arr[], int len){
    for (int i = 0 ; i < len - 1 ; i++){
        int min = i;
        for (int j = i + 1; j < len; j++)     //查询未排序的元素
            if (arr[j] < arr[min])    //找到目前最小值
            min = j;    //记录最小值
        int temp;    //做交换
        temp = arr[min];
        arr[min] = arr[i];
        arr[i] = temp;
    }
}

 
 

四、插入排序

解析插入排序

  插入排序是一种从序列左端开始依次对数据进行排序的算法。在排序过程中,左侧的数据陆续归位,而右侧留下的就是还未被排序的数据。插入排序的思路就是从右侧的未排序区域内取出一个数据,然后将它插入到已排序区域内合适的位置上。
  插入排序的思想和我们打扑克牌的时候一样,从牌堆例一张一张摸起来的牌都是乱序的,我们会把摸起来的牌插入到左手中合适的位置,让左手中的牌时刻保持一个有序的状态。
常见排序算法_第21张图片
常见排序算法_第22张图片
  首先,我们假设最左边的数字 7 已经完成排序,所以此时只有 7 是已归位的数字。
常见排序算法_第23张图片
  接下来,从待排数字(未排序区域)中取出最左边的数字13,将它与左边已归位的数字进行比较。7<13,所以13不需要动,直接结束第2轮排序。
常见排序算法_第24张图片
  接下来,从待排数字(未排序区域)中取出最左边的数字4,将它与左边已归位的数字进行比较。若左边的数字更大,就交换这两个数字。重复该操作,直到左边已归位的数字比取出的数字更小,或者取出的数字已经被移动整个序列的最左边为止。
常见排序算法_第25张图片

  4<13,13向右移动!下一步将4与7进行比较。
常见排序算法_第26张图片
  由于7>4,所以交换着两个数字。
常见排序算法_第27张图片
  所以4也归位了,右边还有5个数字尚未排序。
常见排序算法_第28张图片
常见排序算法_第29张图片
总结:
  如果取出的数字比左边已归位的数字都要小,就必须不停地比较大小,交换数字,直到它到达整个序列的最左边为止。具体说,就是第k轮需要 k-1 次。因此,在最糟糕的情况下,第2轮需要操作 1 次,第3轮操作 2 次……第 n 轮操作 n-1 次,所以时间复杂度和冒泡排序的一样,都为O(n2)。
  最好情况的时间复杂度是O(n),最坏情况的时间复杂度是O(n2)。

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(1),只需要一个辅助空间。

时间复杂度:
  在插入排序中,当待排序数组是有序时,是最优的情况,只需当前数跟前一个数比较一下就可以了,这时一共需要比较N- 1次,时间复杂度为 O(N)。
  最坏的情况是待排序数组是逆序的,此时需要比较次数最多,总次数记为:1+2+3+…+N-1,所以,插入排序最坏情况下的时间复杂度为 O(N2)。

比较次数、移动次数: 当待排序序列正序时,所需进行关键字间比较的次数达最小值 n-1,不需要移动;反之,当待排序序列逆序时,总的比较次数达最大值 (n+2)(n-1)/2,记录移动的次数也达到最大值 (n+4)(n-1)/2。若到待排序列是随机的,即待排序列中的记录可能出现的各种排列的概率相同,则我们取上述最小值和最大值的平均值,作为直接插入排序时所需进行比较次数和移动次数,约为 n2/4。

稳定性: 插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,就是第一个元素。比较是从有序序列的末尾开 始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面,否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相 等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的
 
 

代码实现

void insertion_sort(int arr[], int len){
    int key;
    for (int i=1;i<len;i++){
        key = arr[i];
        int j=i-1;
        while((j>=0) && (arr[j]>key)) {
            arr[j+1] = arr[j];
            j--;
        }
        arr[j+1] = key;
    }
}

 
 

五、希尔排序

解析希尔排序

  希尔排序这个名字,来源它的发明者希尔,也称作“缩小增量排序”,是插入排序的一种更高效的改进版本。
  希尔排序是基于插入排序的以下两点性质而提出改进方法的:

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

  希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
  将一个序列分成好几个序列,用一个数来表示:那个数称为增量。显然的是,增量是不断递减的(直到增量为1)。
  增量一般:{n/2,(n/2)/2…1},每次增量都/2。
常见排序算法_第30张图片
常见排序算法_第31张图片
  分为四组:{ 7,8 },{ 13,1 },{ 4,11 },{ 5,9 }
常见排序算法_第32张图片
  将四组分别插入排序:{ 7,8 },{ 1,13 },{ 4,11 },{ 5,9 }
  增量更新: 4 / 2 = 2
常见排序算法_第33张图片
  增量为2。
  分为两组:{ 7,4,8,11 },{ 1,5,13,9 }
常见排序算法_第34张图片
  将两组分别插入排序:{ 4,7,8,11 },{ 1,5,9,13 }
  增量更新: 2 / 2 = 1
常见排序算法_第35张图片
  增量为1。
  分为两组:{ 4,1,7,5,8,9,11,13 }
常见排序算法_第36张图片
总结:
时间复杂度情况如下:(n指待排序序列长度)

  1. 最好情况:序列是正序排列,在这种情况下,需要进行的比较操作需(n-1)次。后移赋值操作为0次。即O(n)
  2. 最坏情况:O(nlog2n)。
  3. 渐进时间复杂度(平均时间复杂度):O(nlog2n)

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(1),只需要一个辅助空间。

时间复杂度: 希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比 O(n2) 好一些。
  当数组初态基本有序时直接插入排序所需的比较和移动次数均较少。
  当 n 值较小时,n 和 n2 的差别也较小,即直接插入排序的最好时间复杂度 O(n) 和最坏时间复杂度 O(n2) 差别不大。
  在希尔排序开始时增量较大,分组较多,每组的记录数目少,故各组内直接插入较快,后来增量 di 逐渐缩小,分组数逐渐减少,而各组的记录数目逐渐增多,但由于已经按 di-1 作为距离排过序,使数组较接近于有序状态,所以新的一趟排序过程也较快。
  当增量序列为 dlta[k] = 2 t-k+1 - 1时,希尔排序的时间复杂度为 O(n3/2),其中 t 为排序趟数 1 <= k <= t <= log2(n+1)。

比较次数、移动次数: 在 n 在某个特定范围内,希尔排序所需的比较和移动次数约为 n1.3,当 n->∞时,可减少到 n(log2n)2

稳定性: 由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元 素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的

 
 

代码实现

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;
        }
}

 
 

六、归并排序

解析归并排序

  归并排序算法会把序列分成长度相同的两个子序列,当无法继续往下分时(也就是每个子序列只有一个数据时),就对子序列进行归并。归并指的是吧两个排好的子序列合并成一个有序序列。该操作会一直重复执行,直到所有子序列都归并为一个整体。
常见排序算法_第37张图片
常见排序算法_第38张图片
常见排序算法_第39张图片
常见排序算法_第40张图片
  分割完毕!接下来对分割后的元素进行合并。
常见排序算法_第41张图片
  合并7和13,合并后的顺序为[ 7,13 ]。
常见排序算法_第42张图片
  合并4和5,合并后的顺序为[ 4,5 ]。
常见排序算法_第43张图片
  [ 7,13 ]和[ 4,5 ]合并,要先比较首位数字,再移动较小的数字。
常见排序算法_第44张图片
常见排序算法_第45张图片
常见排序算法_第46张图片
  右半边也使用相同的方法。
常见排序算法_第47张图片
常见排序算法_第48张图片
常见排序算法_第49张图片
总结:
  归并排序中,分割序列所花费的时间不算在运行时间内(可以当作序列本来就是分割好的)。在合并两个已排好序的子序列时,只需重复比较首位数据的大小,然后移动较小的数据,因此只需花费和两个子序列的长度相应的运行时间。也就是说,完成一行归并所需的运行时间取决于这行数据的两。
  从上图可以看出,每行数据量都是n,每行的运行时间都是O(n)。而将一个数据量为n的序列对半分割,可以分成 log2n 行。因此,总的运行时间就是O(nlogn)。

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

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

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

比较次数、移动次数: 归并排序的比较次数小于快速排序的比较次数,移动次数一般多于快速排序的移动次数。
  对于一对长度为N的数组,进行合并所需要的比较次数最多为2 * N -1。比较次数:nlog2(n)/2 ~ nlog2(n)-n+1。
  归并排序引入了一个与初始序列长度相同的数组来存储合并后的结果,因而不涉及交换

稳定性: 归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个元素(1次比较和交换),然后把各个有序的段序列合并成一个有 序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结 果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法

 
 

代码实现

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) {//归并排序的分割:1、2、4、8、16... 
        for (start = 0; start < len; start += seg * 2) {//行,一次循环跳一个归并 
            int low = start, mid = min(start + seg, len), high = min(start + seg * 2, len);//low:第一个序列的开始 mid:第一个序列的结束、第二个序列的开始 high:第二个序列的结束 
            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);//释放临时空间 
}

 
 

七、快速排序

解析快速排序

  快速排序的核心思想也是分治法,分而治之。它的实现方式是每次从序列中选出一个基准值,其他数依次和基准值做比较,比基准值大的放左边,然后再对左边和右边的两组数分别选择出一个基准值,进行同样的比较移动,重复步骤,直到都变成单个元素,整个数组就成了有序的序列。

[ 比基准值小的数 ] 基准值 [ 比基准值大的数 ]
  不断分成两半,直到 [ ] 中只有一个数据为止。

常见排序算法_第50张图片
常见排序算法_第51张图片
  首先,我们选一个基准值,一般我们选取中间的为基准值。
  从数组剩下的序列左右两边进行扫描,先从左往右找到一个大于等于基准值的元素,将下标指针记录下来,然后转到从右往左扫描,找到一个小于等于基准值的元素,交换这两个元素的位置,重复上述步骤,直到左右两个指针相遇,再将基准值与左侧、右侧的数分别进行上述操作。
常见排序算法_第52张图片
  左边扫描到7,右边扫描到1,交换。

常见排序算法_第53张图片
  左边扫描到13,右边扫描到5,交换。
常见排序算法_第54张图片
  该轮结束,数组分为两块。
常见排序算法_第55张图片
  分为两块后,继续进行上述的操作。
  我们先看左边这块。
常见排序算法_第56张图片
常见排序算法_第57张图片
  继续分块。
常见排序算法_第58张图片
常见排序算法_第59张图片
常见排序算法_第60张图片
  指针相遇继续分块。
常见排序算法_第61张图片
常见排序算法_第62张图片
  其他的也是类似的操作
常见排序算法_第63张图片
总结:
  快速排序是一种“分治法”。它将原本的问题分成两个子问题(比基准值小的数和比基准值大的数),然后再分别解决这两个问题。子问题,也就是子序列完成排序后,再像一开始说明的那样,把他们合并成一个序列,那么对原始序列的排序也就完成了。
  不过,解决子问题的时候会再次使用快速排序,甚至在这个快速排序里仍然要使用快速排序。只有在子问题里只剩一个数字的时候,排序才算完成。
  分割子序列时需要选择基准值,如果每次选择的基准值都能使得两个子序列的长度为原本的一半,那么快速排序的运行时间和归并排序的一样,都为O(nlogn)。和归并排序类似,将序列对半分割logn次之后,子序列里便只剩下一个数据,这时子序列的排序也就完成了。因此,如果像下图这样一行行地展现根据基准值分割序列的过程,那么总共会有logn行。
  每行中每个数字都需要和基准值比较大小,因此每行所需的运行时间为On)。由此可知,整体的时间复杂度为O(nlogn)o
  如果运气不好,每次都选择最小值作为基准值,那么每次都需要把其他数据移到基准值的右边,递归执行n行,运行时间也就成了O(n’)。这就相当于每次都选出最小值并把它移到了最左边,这个操作也就和选择排序一样了。此外,如果数据中的每个数字被选为基准值的概率都相等,那么需要的平均运行时间为 O(nlogn)。

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(log n),快速排序只需要一个元素的辅助空间,但快速排序需要一个栈空间来实现递归。最好的情况下,即快速排序的每一趟排序都将元素序列均匀地分割成长度相近的两个子表,所需栈的最大深度为log2(n+1);但最坏的情况下,栈的最大深度为 n。这样,快速排序的空间复杂度为O(log2n)。

时间复杂度: 快速排序的一次划分算法从两头交替搜索,直到low和hight重合,因此其时间复杂度是O(n);而整个快速排序算法的时间复杂度与划分的趟数有关。
  理想的情况是,每次划分所选择的中间数恰好将当前序列几乎等分,经过log2n趟划分,便可得到长度为1的子表。这样,整个算法的时间复杂度为 O(nlog2n)
  最坏的情况是,每次所选的中间数是当前序列中的最大或最小元素,这使得每次划分所得的子表中一个为空表,另一子表的长度为原表的长度-1。这样,长度为n的数据表的快速排序需要经过n趟划分,使得整个排序算法的时间复杂度为 O(n2)

比较次数、移动次数: 理想的情况下比较次数:nlog(n);最坏情况比较次数:n(n-1)/2。移动次数无法分析。

稳定性: 快速排序有两个方向,左边的i下标一直往右走,当a[i] <= a[center_index],其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]。如果 i 和 j 都走不动了,i <= j, 交换 a[i] 和 a[j],重复上面的过程,直到 i > j。 交换 a[j] 和 a[center_index],完成一趟快速排序。在中枢元素和 a[j] 交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为 5 3 3 4 3 8 9 10 11, 现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j] 交换的时刻。

 
 

代码实现

void quick_sort(int q[], int l, int r)//l起始下标,r结束下标 
{
    if (l >= r) return;

    int i = l - 1, j = r + 1, x = q[l + r >> 1];//x为基准值,去中间点 
    while (i < j)
    {
        do i ++ ; while (q[i] < x);//扫描大于基准值的数 
        do j -- ; while (q[j] > x);//扫描小于基准值的数 
        if (i < j) swap(q[i], q[j]);//交换q[i]和q[j] 
    }
    quick_sort(q, l, j), quick_sort(q, j + 1, r);
}

 
 

八、堆排序

解析堆排序

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

  1. 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
  2. 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;

堆排序的平均时间复杂度为 Ο(nlogn)。

  数组是线性结构,我们构建堆一般如下图所示:节点下标为 n,它在左孩子为 2n+1,右孩子为 2n+2。
常见排序算法_第64张图片

常见排序算法_第65张图片
  开始排序。
常见排序算法_第66张图片
  建堆。
常见排序算法_第67张图片
  我们是从小到大排序,应该构建大顶堆。下面我们开始构建大顶堆。这系列操作属于初始化阶段。
常见排序算法_第68张图片
  最后一个父节点为5,比较5与9,大顶堆需要交换。而无后续的孙节点,所以本次结束。
常见排序算法_第69张图片

  下一个父节点为4,比较4、1、11,交换4与11而无后继的孙节点,结束.
常见排序算法_第70张图片

  下一个父节点为13,比较13、9、8,无需交换。
常见排序算法_第71张图片

  下一个父节点为7,比较7、13、11,交换7与13由于有孙节点,所以继续往下去维护大顶堆。
常见排序算法_第72张图片

  延续下去的父节点为7,比较7、9、8,交换7与9由于有孙节点,所以继续下午维护大顶堆。
常见排序算法_第73张图片
  延续下去的父节点为7,比较7、5,交换7与5无孙节点,所以结束。
常见排序算法_第74张图片
  大顶堆的初始化结束,开始排序。
常见排序算法_第75张图片
  使顶堆与已排序的前一个进行交换,在从堆顶进行维护大顶堆。
常见排序算法_第76张图片
  由于交换会破坏大顶堆,所以我们需要对其进行维护。而我们只需要从堆顶进行自上而下的维护即可,而不会像初始化一样重新来一遍,因为交换只会破坏最上层的。
常见排序算法_第77张图片
  父节点为:5,比较5,9,11,交换5和11有孙节点所以需要继续向下!
常见排序算法_第78张图片
  父节点为:5,比较5,1,4,无需交换,结束本次维护!剩下的元素再次构成一个大顶堆。
  下面只需要重复上面的排序步骤,直到堆为空即可完成排序。
常见排序算法_第79张图片
总结:
  堆排序一开始需要将n个数据存进堆里,所需时间为O(nlogn)。排序过程中,堆从空堆的状态开始,逐渐被数据填满。由于堆的高度小于log2n,所以插入1个数据所需要的时间为O(logn)o
  每轮取出最大的数据并重构堆所需要的时间为O(logn)。由于总共有n轮,所以重构后排序的时间也是O(nlogn)。因此,整体来看堆排序的时间复杂度为O(nlogn)。
  这样来看,堆排序的运行时间比之前讲到的冒泡排序、选择排序、插入排序的时间O(n2)都要短,但由于要使用堆这个相对复杂的数据结构,所以实现起来也较为困难。

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(1),只需要一个辅助空间。

时间复杂度: O(NlogN),堆排序是采用的二叉堆进行排序的,二叉堆就是一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的定义,它的深度至少是log(N+1)。最多是多少呢?由于二叉堆是完全二叉树,因此,它的深度最多也不会超过log(2N)。因此,遍历一趟的时间复杂度是O(N),而遍历次数介于log(N+1)和log(2N)之间;因此得出它的时间复杂度是O(N*logN)。

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

稳定性: 堆排序是不稳定的算法,它不满足稳定算法的定义。它在交换数据的时候,是比较父结点和子节点之间的数据,所以,即便是存在两个数值相等的兄弟节点,它们的相对顺序在排序也可能发生变化。

 
 

代码实现

#include 
#include 

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;
}

 
 

九、计数排序

解析计数排序

  计数排序是一种非基于比较的排序算法,我们之前介绍的各种排序算法几乎都是基于元素之间的比较来进行排序的,计数排序的时间复杂度为 O(n + m ),m 指的是数据量,说的简单点,计数排序算法的时间复杂度约等于 O(n),快于任何比较型的排序算法,但是空间复杂度较高
  计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

算法的步骤如下:

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

常见排序算法_第80张图片
常见排序算法_第81张图片
常见排序算法_第82张图片
  这样排序就结束了!
总结:
  计数排序十分好理解,实现也简单,时间复杂度也十分优秀,且是稳定的。但是如果数据的范围较大,数据较为稀疏时,会导致需要消耗大量的空间,且很多空间可能用不上,空间复杂度较高。
  计数排序只适用于正整数并且取值范围相差不大的数组排序使用,它的排序的速度是非常快的。

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: 空间复杂度为**O(k)**同理 k 为要排序的最大值。

时间复杂度: 时间复杂度O(n+k),其中n为要排序的数的个数,k为要排序的数的组大值。

比较次数、移动次数: 计数排序不是一个比较算法,它的代码中完全没有输入元素之间的比较操作。

稳定性:计数排序的一个重要性质就是它是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输人数组中的相对次序相同。也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。通常,这种稳定性只有当进行排序的数据还附带卫星数据时才比较重要。计数排序的稳定性很重要的另一个原因是:计数排序经常会被用作基数排序算法的一个子过程。我们将在下一节中看到,为了使基数排序正确运行,计数排序必须是稳定的。

 
 

代码实现

void counting_sort(int *a,int n){
	 //找出数组中的最大值
    int max = a[0];
    for (int i = 1; i < n; i++) {
        if (a[i] > max) {
            max = a[i];
        }
    }
    //初始化计数数组
    int arr[max+1];
    for(int i = 0;i < max+1;i++)
    	arr[i] = 0;

    //计数
    for (int i = 0; i < n; i++) {
        arr[a[i]]++;
        a[i] = 0;
    }

    //排序
    int index = 0;
    for (int i = 0; i < max+1; i++) {
        while (arr[i] > 0) {
            a[index++] = i;
            arr[i]--;
        }
    }
}

 
 

十、桶排序

解析桶排序

  桶排序可以看成是计数排序的升级版,它将要排的数据分到多个有序的桶里,每个桶里的数据再单独排序,再把每个桶的数据依次取出,即可完成排序。
常见排序算法_第83张图片

  length:8
  (max-min)/(length)=1.5
常见排序算法_第84张图片
  将桶划分好,向里面填写数据,再分别对桶内的数据进行排序。
常见排序算法_第85张图片
总结:
  为了使桶排序更加高效,我们需要做到这两点:

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

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

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(n * k)

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

比较次数、移动次数: 桶排序也不是比较算法。

稳定性: 桶排序是否稳定取决于"桶"用什么数据结构实现,如果是队列,那么可以保证相同的元素"取出去"后的相对位置与"放进来"之前是相同的,即排序是稳定的,而如果用栈来实现"桶",则排序一定是不稳定的,因为桶排序可以做到稳定,所以桶排序是稳定的排序算法

 
 

代码实现

桶内排序算法可以随意使用。(时间复杂度不同)

public static void bucket_sort(int[] arr){
	    //最大最小值
	    int max = arr[0];
	    int min = arr[0];
	    int length = arr.length;

	    for(int i=1; i<length; i++) {
	        if(arr[i] > max) {
	            max = arr[i];
	        } else if(arr[i] < min) {
	            min = arr[i];
	        }
	    }

	    //最大值和最小值的差
	    int diff = max - min;

	    //桶列表
	    ArrayList<ArrayList<Integer>> bucketList = new ArrayList<>();
	    for(int i = 0; i < length; i++){
	        bucketList.add(new ArrayList<>());
	    }

	    //每个桶的存数区间
	    float section = (float) diff / (float) length;
	    //System.out.println(section);

	    //数据入桶
	    for(int i = 0; i < length; i++){
	        //当前数除以区间得出存放桶的位置
	        int num = (int) ((arr[i]-min) / section);
	        //最大数除出的超限了,将最大值控制放在最后一个桶内
	        if(arr[i] == max)
	        	num = length - 1;
	        if(num < 0){
	            num = 0;
	        }
	        bucketList.get(num).add(arr[i]);
	    }

	    //桶内排序
	    for(int i = 0; i < bucketList.size(); i++){
	        //jdk的排序速度当然信得过
	    	//System.out.println((section*i+min) + ":" + bucketList.get(i));
	        Collections.sort(bucketList.get(i));
	    }

	    //写入原数组
	    int index = 0;
	    for(ArrayList<Integer> arrayList : bucketList){
	        for(int value : arrayList){
	            arr[index] = value;
	            index++;
	        }
	    }
	}

 
 

十一、基数排序

解析基数排序

  基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
  基数排序的方式可以采用LSD(Least significant digital)或MSD(Most significant digital),LSD的排序方式由键值的最右边开始,而MSD则相反,由键值的最左边开始。

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

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

常见排序算法_第86张图片
常见排序算法_第87张图片

  按照数组的顺序使他们依次进入队列,取出时前进先出
常见排序算法_第88张图片

  刚刚排列的是个位,按照从低位到高位的排列顺序,还需要排列一下十位。
常见排序算法_第89张图片
5
  十位是最高位!排序结束!
总结:
  它一种与其他排序算法完全不同的排序方法,其他的排序算法都是通过关键字之间的比较和移动来完成的,而它是采用一种多关键字的思想。
  多关键字的思想:给定一组数据,我可以先按个位的大小对所有数进行排序,然后再按十位进行排序,一直到最高位,这样就可以使整组数据变得有效,这样从最低位开始的方法称为最低位优先。

 
 

稳定性、比较次数、移动次数、时间复杂度、空间复杂度

空间复杂度: O(d + N)

时间复杂度: 时间复杂度可以理解为O(d*n),d为序列中最大的位数,适用于n值很大,但是关键字较小的序列。

比较次数、移动次数: 基数排序也不是比较算法。

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

 
 

代码实现

顺序实现:

int GetNum(int num,int pos){//获取num的第pos位数字 

	int temp = 1,i;
	for(i=1;i<pos;i++)
	 	temp*=10;
	return (num/temp)%10; 
	
}


void RadixSort(int *s,int n){//基数排序 
	
	int b[10][10001] = {0};
	int c[10] = {0};
	for(int i=1;i<=4;i++){//数据最大四位数 
		int j;
		for(j=0;j<n;j++){//让数组中的数根据第j位放置于桶中 
			int d = GetNum(s[j],i);
			b[d][c[d]++] = s[j];
		}
		int m = 0; 
		for(j=0;j<10;j++){//将排序好的一组数,放回数组中 
			int k;
			for(k=0;k<c[j];k++){
				s[m++] = b[j][k];
			}
			c[j] = 0;
		}
	}
	
}

链式实现
常见排序算法_第90张图片

#include
#include

typedef struct SLCell{//存放数据 
	int data;//数据 
	struct SLCell *next;//下一个节点 
}SLCell;

int GetNum(int num,int pos){//获取num的第pos位数字 

	int temp = 1,i;
	for(i=1;i<pos;i++)
	 	temp*=10;
	return (num/temp)%10; 
	
}

void RadixSort(SLCell *head){//链式基数排序 
	SLCell *list[10];//位数链表的开始 
	SLCell *end[10];//位数链接的结尾 
	
	for(int i=0;i<10;i++){//初始化位数链表 
		list[i] = (SLCell *)malloc(sizeof(SLCell));
		list[i]->next = NULL;
		end[i] = list[i];
	}
	
	for(int i=1;i<=4;i++){//四位数排序 
		SLCell *p = head->next;
		while(p){
			int d = GetNum(p->data, i);
			end[d]->next = p;
			end[d] = end[d]->next;
			p = p->next;
			end[d]->next = NULL;
		}

		p = head;
		for(int j=0;j<10;j++){//将位数链表中的数取出 
			SLCell *q;
			q = list[j]->next;
			list[j]->next = NULL;//将位数链表中的数清空 
			end[j] = list[j];
			while(q){
//				printf("%d %d\n",j,q->data);
				p->next = q;
				p = p->next;
				q = q->next;
				p->next = NULL;
			}
		}
	}
}

int main()
{
	int n;
	scanf("%d",&n);
	SLCell *head = (SLCell *)malloc(sizeof(SLCell));
	head->next = NULL;
	SLCell *q = head;
	for(int i=0;i<n;i++){
		SLCell *p = (SLCell *)malloc(sizeof(SLCell));
		p->next = NULL;
		scanf("%d",&p->data);
		q->next = p;
		q = q->next;
	}
	RadixSort(head);
	head = head->next;
	while(head){
		printf("%d ",head->data);
		head = head->next;
	}
	return 0;
}

你可能感兴趣的:(数据结构与算法,排序算法,冒泡排序,插入排序,选择排序,快速排序)