目录
一、排序的思维导图
二、排序的基本概念
1、排序:
所谓排序,即将原本无序的序列重新排列成有序序列的过程。这个序列中的每一项可能是单独的数据元素,也可能是一条记录(记录是由多个数据元素组成的,如一个学生记录就是由学号、姓名、年龄、专业等数据元素组成的)。如果是记录,则既可以按照记录的主关键字排序(主关键字唯一标识一条记录,如学生记录中的学号就是主关键字,学号不能重复,用来唯一标识一个学生),也可以按照记录的次关键字排序(如学生记录中的姓名、专业等都是次关键字,次关键字是可以重复的)。
2、稳定性:
所谓稳定性是指当待排序序列中有两个或两个以上相同的关键字时,排序前和排序后这些关键字的相对位置如果没有发生变化就是稳定的,否则就是不稳定的。例如,某序列有两个关键字都是50,以50(a)和 50(b)来区分它们,用某种算法 A 对其排序,排序前50(a)在50(b)之前,如果排序后50(a)仍然在 50(b)之前,则 A 是稳定的;如果能找出一种情况,使排序后50(a)在50(b)之后,则 A 是不稳定的。
如果关键字不能重复,则排序结果是唯一的,那么选择的排序算法稳定与否就无关紧要:如果关键字可以重复,则在选择排序算法时,就要根据具体的需求来考虑选择稳定的还是不稳定的排序算法。
3、排序算法的分类:
(1)插入类的排序:
在一个已经有序的序列中,插入一个新的关键字,就好比军训排队,已经排好了一个纵队。这时,有人要临时加到这个队里来,于是教官喊道:“新来的,迅速找到你的位置,入队!”于是新来的“插入”到这个队伍的合适位置中。这就是“插入”类的排序。属于这类排序的有直接插入排序、折半插入排序、希尔排序。
(2)交换类的排序:
交换类排序的核心是“交换”,即每一趟排序,都通过一系列的“交换”动作,让一个关键字排到它最终的位置上。还是军训排队的例子,设想军训刚开始,一群学生要排队,教官说:“你比你旁边的高,你俩换一下。怎么换完还比下一个高?继续换……”最后这个同学将被换到最终位置。这就是“交换”类的排序。属于这类排序的有冒泡排序(刚才排队的例子)、快速排序。
(3)选择类的排序:
选择类排序的核心是“选择”,即每一超排序都选出一个最小(或最大)的关键字,把它和序列中的第一个(或最后一个)关键字交换,这样最小(或最大)的关键字到位。继续军训排队,教官说:“你们都站着别动,我看谁个子最小。”然后教官选出个子最小的同学,说“第一个位置是你的了,你和第一个同学换一下,剩下的同学我继续选。”这就是“选择”类的排序。属于这类排序的有简单选择排序、堆排序。
(4)归并类的排序:
所谓归并就是将两个或两个以上的有序序列合并成一个新的有序序列,归并类排序就是基于这种思想,我们继续排队,这次教官想了个特别的方法,他说:“你们每个人,先和旁边的人组成一个二人组,二人组内部先排好。”看到大家排好了,继续说:“二人组和旁边的二人组继续组合成一个四人组,每个四人组内部排好,动作快!”这样不停排下去,最后全部学生都归并到了一个组中,同时也就排好序了。这就是“归并”类的排序。这个例子正是二路归并排序,特点是每次都把两个有序序列归并成一个新的有序序列。
(5)基数类的排序:
基数类的排序是最特别的一类,跟前面的思想完全不同(前面都是要进行“比较”和“移动”这两个操作)。基数类的排序基于多关键字排序的思想,把一个逻辑关键字拆分成多个关键字。例如,对一副去掉大小王的52张扑克牌进行基数排序,可以先按花色排序(如按红桃、黑桃、方片和梅花的顺序),这样就分成了4堆,然后每一堆再按照从A到K的顺序,排序使这副牌最终有序。
三、插入类排序
1、直接插入排序:
(1)执行流程:原始序列:{49,38,65,97,13,27,49}
(2)算法思想:每次将一个待排序的关键字按照其值的大小插入到已经排好的部分有序序列的位置上,直到所有关键子都被插入到有序序列中为止。直接插入排序的算法代码如下:
void InsertSort(int R[],int n){ //将待排关键字存储到R[]中,默认为整型,个数为 n
int i,j,temp;
for(i=1;i<n;i++){
temp = R[i]; //将待插入关键字暂存于 temp 中
j = i-1;
while(j>0 && temp<R[j]){ //如果当前位置关键字大于待排关键字,则将其后移一位
R[j+1] = R[j];
j--;
}
R[j+1] = temp; //找到插入位置,将temp中暂存的关键字插入
}
}
(3)时间复杂度分析:由插入排序算法代码,可以选取最内层循环中的 R[j+1]=R[ j ];这一句作为基本操作。
1)考虑最坏的情况,即整个序列是逆序的,则内层循环中temp < R[ j ]这个条件是始终成立的。此时对于每一次外层循环,最内层循环的执行次数(也是基本操作的执行次数)达到最大值,为 i 次(如当外层循环进行到 i 等于5时,内层循环 j 取从 0 到4,执行5次)。i 取值为 1 到 n-1,由此可得基本操作总的执行次数为(n-1+1)(n-1)/2 = n( n-1 ) / 2,可以看出时间复杂度为O(n^2)。
2)考虑最好的情况,即整个序列已经有序,则对于内层循环中 temp < R [ j ]这个条件是始终不成立的。此时内层循环始终不执行,双层循环就变成了单层循环,循环内的操作皆为常量级,比较次数为 n -1,无须进行移动,显然时间复杂度为O(n)。综合上述两种情况,本算法的平均时间复杂度为0(n^2)。
(4)空间复杂度分析:
算法所需的辅助存储空间不随待排序列规模的变化而变化,是个常量,因此空间复杂度为O(1)。
2、折半插入排序:
折半插入法的一个基本条件是序列已经有序,而从直接插入排序的流程中可以看出,每次都是在一个已经有序的序列中插入一个新的关键字,因此可以用折半查找法在这个有序序列中查找插入位置。
(1)执行流程:已经排序的序列{13,38,49,65,76,97} 、待排序列:{27,49}
(2)时间复杂度分析:
折半插入排序适合关键字数较多的场景,与直接插入排序相比,折半插入排序在查找插入位置上面所花的时间大大减少。折半插入排序在关键字移动次数方面和直接插入排序是一样的,所以其时间复杂度和直接插入排序还是一样的。折半插入排序的关键字比较次数和初始序列无关。因为每趟排序折半查找插入位置时,折半次数是一定的(都是在low>high时结束),折半一次就要比较一次,所以比较次数是一定的。由此可知折半插入排序的时间复杂度最好情况为O(nlog2n),最差情况为O(n^2),平均情况为O(n^2)。
(3)空间复杂度分析
空间复杂度同直接插入排序一样,为O(1)。
3、希尔排序:
(1)算法思想:希尔排序又叫作缩小增量排序,其本质还是插入排序,只不过是将待排序列按某种规则分成几个子列,分别对这几个子序列进行直接插入排序。这个规则的体现就是增量的选取,如果增量为1,就是直接插入排序。例如,先以增量 5 来分割序列,即将下标为0、5、10、15…的关键字分成一组,将下标为1、6、11、16…的关键字分成另一组等,然后分别对这些组进行直接插入排序,这就是一趟希尔排序。将上面排好序的整个序列,再以增量 2 分割,即将下标为0、2、4、6、8.…的关键字分成一组,将下标为1、3、5、7、9…的关键字分成另一组等,然后分别对这些组进行直接插入排序,这又完成了一趟希尔排序。最后以增量 1 分割整个序列,其实就是对整个序列进行一趟直接插入排序,从而完成整个希尔排序。
注意到增量5、2、1是逐渐缩小的,这就是缩小增量排序的由来。直接插入排序适合于序列基本有序的情况,希尔排序的每趟排序都会使整个序列变得更加有序,等整个序列基本有序了,再来一趟直接插入排序,这样会使排序效率更高,这就是希尔排序的思想。
(2)执行流程:
(3)时间复杂度分析:
希尔排序的时间复杂度分析和增量选取有关, 希尔排序的增量选取规则有很多,常见的有:
① 每次将增量除以 2 并向下取整,其中 n 为序列长度,此时时间复杂度为O(n^2)。
② 帕佩尔诺夫和斯塔舍维奇提出的:2^k +1、...、65、33、17、9、5、3、1。(int k>=1,1是额外添加的)O(n^1.5)。
(4)空间复杂度分析
空间复杂度同直接插入排序一样,为O(1)。
注意:增量序列的最后一个值一定取 1。增量序列中的值尽量没有除 1 以外的公因子。
四、交换类排序
1、冒泡排序:
(1)算法介绍:
冒泡排序又称起泡排序。它是通过一系列的“交换”动作完成的。首先第一个关键字和第二个关键字比较,如果第一个大,则二者交换,否则不交换;然后第二个关键字和第三个关键字比较,如果第二个大,则二者交换,否则不交换……一直按这种方式进行下去,最终最大的那个关键字被交换到了最后,一趟起泡排序完成。经过多趟这样的排序,最终使整个序列有序。
(2)算法流程:
至此一趟冒泡排序结束,最大的 97 被交换到了最后,97 到达了它最后的位置。接下来对序列 38,49,65,76,13,27,49 按照同样的方法进行第二趟冒泡排序。经过若干趟冒泡排序后,最终序列有序。要注意的是,冒泡排序算法结束的条件是在一趟排序过程中没有发生关键字交换。
(3)冒泡排序的代码如下:
void BubbleSort(int[],int n){ //默认待排序关键字为整型
int i,j,flag,temp;
for(i=n-1;i>=1;i--){ //i指向待排序序列的最后一个位置
flag = 0; //变量flag用来标记本趟排序是否发生了交换
for(j=1;j<=i;j++){ //j=1指向数组第二个元素
if(R[j-1]>R[j]){
temp = R[j];
R[j] = R[j-1];
R[j-1] = temp;
flag = 1; //如果发生交换,则flag值为 1
}
}
if(flag == 0) //一趟排序中如果没有发生关键字交换,说明序列有序,排序结束
return;
}
}
(4)时间复杂度分析:由起泡排序算法代码可知,可选取最内层循环中的关键字交换操作作为基本操作。
1)最坏情况,待排序列逆序,此时对于外层循环的每次执行,内层循环中 if 语句的条件R[ j ] > R[ j-1 ]始终成立,即基本操作执行的次数为 n-i 。i 的取值为1~n-1。因此,基本操作总的执行次数为(n-1+1)(n-1)/2 = n( n-1 ) / 2,由此可知时间复杂度为O(n^2)。
2)最好情况,待排序列有序,此时内层循环中if语句的条件始终不成立,交换不发生,且内层循不执行 n-1 次后整个算法结束,可见时间复杂度为O(n)。综合以上两种情况,平均情况下的时间复杂度为O(n2)。
(5)空间复杂度分析
由算法代码可以看出,额外辅助空间只有一个temp,因此空间复杂度为O(1)。
2、快速排序:
(1)算法介绍:快速排序也是“交换”类排序,它通过多次划分操作实现排序。它的基本思想是:任取一个元素(如:第一个)为中心元素,所有比它小的元素一律前放,比它大的元素一律后放,分成两个子表,然后再对各子表重新选择中心元素并依此规则调整,直到每个子表的元素只剩一个。
(2)执行过程:
可以看出一趟划分后,将原来的序列以 49 为中心(枢轴),划分为两部分,49左边的数都小于或等于它,右边的数都大于或等于它,接下来以同样的方法对序列{27,38,13}和序列{76,97,64,49}分别进行排序。经过几趟划分,最终得到一个有序序列。
(3)快速排序算法代码如下:
void QuickSort(int R[],int low,int high){ //对R[low]到R[high]的关键自进行排序
int i=low,j=high,temp;
if(low < high){
temp = R[low]; //将第一个元素存入temp中作为枢轴
while(i < j){
while(i<j && R[j]>=temp) //从右往左扫描,找到一个小于temp的关键字
j--;
if(i<j){ //找到之后将其放在temp左边,i++
R[i] = R[j];
i++;
}
while(i<j && R[i]<temp) //从左往右扫描,找到一个大于temp的关键字
i++;
if(i<j){ //找到之后将其放在temp右边,j--
R[j] = R[i];
j--;
}
}
R[i] = temp; //将temp放在最终位置
QuickSort(R,low,i-1); //递归地对temp左边关键字进行排序
QuickSort(R,i+1,high); //递归地对temp右边边关键字进行排序
}
}
(4)事件复杂度分析:
快速排序最好情况下的时间复杂度为O(nlog2n),待排序列越接近无序,本算法效率越高。最坏情况下的时间复杂度为O(n2),待排序列越接近有序,本算法效率越低。平均情况下时间复杂度为O(nlog2n)。快速排序的排序趟数和初始序列有关。
(5)空间复杂度分析:
本算法的空间复杂度为O(log2n)。快速排序是递归进行的,递归需要栈的辅助,因此需要辅助空间比前几类排序算法大。
五、选择类排序
1、简单选择排序:
(1)算法介绍:
简单排序算法采用最简单的选择方式。从头到尾顺序扫描序列,找出最小的一个关键字,和第一个关键字交换位置,接着从剩下的关键字继续着各种选择和交换,最终使序列有序。
(2)执行流程:在进行选择排序的过程中,把整个序列分成有序和无序部分,开始时,整个序列为无需序列。
(3)简单排序算法代码如下:
void SelectSort(int R[],int n){
int i,j,k,temp;
for(i=0;i<n;i++){
k = i;
for(j=i+1;j<n;j++){ //从整个序列中找出一个最小的关键字
if(R[k]>R[j])
k = j;
}
temp = R[i]; //最小关键字与无序序列第一个关键字交换
R[i] = R[k];
R[k] = temp;
}
}
(4)时间复杂度分析:
通过本算法代码可以看出,两层循环的执行次数和初始序列没有关系,外层循环执行 n 次,内层循环执行n-1次,将最内层循环中的比较操作视为关键操作,其执行次数为(n-1+1 )( n-1)/2 = n(n-1)/2,即时间复杂度为O(n^2)。
(5)空间复杂度分析
算法所需的辅助存储空间不随待排序列规模的变化而变化,是个常量,因此空间复杂度为0(1)。
2、堆排序:
(1)算法介绍:
堆是一种数据结构,可以把堆看成一棵完全二叉树,这棵完全二叉树满足:任何一个非叶结点的值都不大于(或不小于)其左右孩子结点的值。若父亲大孩子小,则这样的堆叫作大顶堆;若父亲小孩子大,则这样的堆叫作小顶堆。
根据堆的定义知道,代表堆的这棵完全二叉树的根结点的值是最大(或最小)的,因此将一个无序序列调整为一个堆,就可以找出这个序列的最大(或最小)值,然后将找出的这个值交换到序列的最后(或最前),这样,有序序列关键字增加 1 个,无序序列中关键字减少1个,对新的无序序列重复这样的操作,就实现了排序。这就是堆排序的思想。
堆排序中最关键的操作是将序列调整为堆。整个排序的过程就是通过不断调整,使得不符合堆定义的完全二叉树变为符合堆定义的完全二叉树。
(2)执行流程:原始序列:{49,38,65,97,76,13,27,49}
① 建堆:原始序列对应的完全二叉树如下图,先将整个序列调整为一个大顶堆。
② 插入节点:
需要在插入结点后保持堆的性质,即完全二叉树形态与父大子小性质(以大顶堆为例),因此需要先将要插入的结点 X 放在最底层的最右边,插入后满足完全二叉树的特点;然后把 X 依次向上调整到合适位置以满足父大子小的性质。
③ 删除节点:
当删除堆中的一个结点时,原来的位置就会出现一个孔,填充这个孔的方法就是,把最底层最右边的叶子的值赋给该孔,并下调到合适位置,最后把该叶子删除。
④ 排序
可以看到,此时已经建立好了一个大顶堆。对应的序列为:97,76,65,49,49,13,27,38。将堆顶关键字 97 和序列最后一个关键字 38 交换。第一趟堆排序完成。97 到达其最终位置。将除 97 外的序列38,76,65,49,49,13,27重新调整为大顶堆。现在这个堆只有 38 是不满足堆定义的,其他的关键字都满足,所以只需要调整一个 38 就够了。
堆排序执行过程描述(以大顶堆为例)如下:
1)从无序序列所确定的完全二叉树的第一个非叶子结点开始,从右至左,从下至上,对每个结点进行调整,最终将得到一个大顶堆。对结点的调整方法:将当前结点(假设为 a)的值与其孩子结点进行比较,如果存在大于 a 值的孩子结点,则从中选出最大的一个与 a 交换。当a来到下一层的时候重复上述过程,直到 a 的孩子结点值都小于 a 的值为止。
2)将当前无序序列中的第一个关键字,反映在树中是根结点(假设为a)与无序序列中最后一个关键字交换(假设为b)。a进入有序序列,到达最终位置:无序序列中关键字减少1个,有序序列中关键字增加1个。此时只有结点 b 可能不满足堆的定义,对其进行调整。
3)重复第 2)步,直到无序序列中的关键字剩下 1 个时排序结束。
(3)堆排序算法代码如下:
void Shift(int R[],int low,int high){
int i=low,j=2*i; //R[j]是R[i]的左孩子
int temp = R[i]; //先将R[i]存到temp中
while(j<=high){
if(j<high && R[j]<R[j+1]) //若右孩子大,则把j指向右孩子
j++; //j变成2*i+1
if(temp<R[j]){
R[iJ=R[j]; //将R[j]调整到双亲节点上
i=j; //修改 i 和 j 的值,以便下次调整
j=2*i;
}else
break; //调整结束
}
R[i]=temp; //被调整元素放入最终位置
}
void heapSort(int R[],int n){ //堆排序函数
int i;
int temp;
for(i=n/2;i>=1;i--){ //建立初始堆
Sift(R,i,n);
}
for(i=n;i>=2;i--){ //进行n-1次循环,完成堆排序
temp=R[1];
R[1]=R[i];
R[i]=temp;
Sift(R,1,i-10); //在减少一个关键字的无序序列中进行调整
}
}
(4)时间复杂度分析
对于函数Sift(),显然 j 走了一条从当前结点到叶子结点的路径,完全二叉树的高度为[log2(n+1)]↑,即对每个结点调整的时间复杂度为O(log2n)。对于函数 heapSort(),基本操作总次数应该是两个并列的 for 循环中的基本操作次数之和,第一个循环的基本操作次数为O(log2n)×n/2,第二个循环的基本操作次数为O(log2n)×(n-1),因此整个算法的基本操作次数为O(log2n)x n/2+O(log2n)×(n-1),化简后得其时间复杂度为O(nlog2n)。
(5)空间复杂度分析:
算法所需的辅助存储空间不随待排序规模的变化而变化,是个常量,因此空间复杂度为O(1).
六、二路归并排序
(1)归并排序算法介绍:先将整个序列分为两半,对每一半分别进行归并排序,将得到两个有序序列,然后将其归并到一起。
(2)执行流程:
(3)算法代码如下:
void mergeSort(int A[],int low,int high){
if(low<high){
int mid = (low + high)/2;
meregeSort(A,low,mid); //归并排序前半段
meregeSort(A,mid+1,high); //归并排序后半段
merege(A,low,mid,high); //将两端序列归并一段有序序列
}
}
(4)时间复杂度分析:
归并排序的时间复杂度和初始序列无关,即平均情况下为O(nlog2n),最好情况下为O(nlog2n),最坏情况下为O(nlog2n)。
(5)空间复杂度分析:
归并排序需要转存整个序列,因此空间复杂度为O(n)。
七、基数排序
(1)算法介绍:
基数排序的思想是“多关键字排序”,前面已经讲过了。基数排序有两种实现方式:第一种叫作最高位优先,即先按最高位排成若干子序列,再对每个子序列按次高位排序。举扑克牌的例子,就是先按花色排成4个子序列,再对每种花色的13张牌进行排序,最终使所有扑克牌整体有序。第二种叫作最低位优先,这种方式不必分成子序列,每次排序全体关键字都参与。最低位可以优先这样进行,不通过比较,而是通过“分配”和“收集”。还是扑克牌的例子,可以先按数字将牌分配到13个桶中,然后从第一个桶开始依次收集;再将收集好的牌按花色分配到4个桶中,然后还是从第一个桶开始依次收集。经过两次“分配”和“收集”操作,最终使牌有序。
(2)执行流程:
(3)时间复杂度分析:
时间复杂度:平均和最坏情况下都是O(d(n+rd))。其中,n 为序列中的关键字数;d 为关键字的关键字位数,如930,由3位组成,d=3;rd 为关键字基的个数,这里的基指的是构成关键字的符号,如关键字为数值时,构成关键字的符号就是0~9这些数字,一共有十个,即rd=10。
这里简单讲解基数排序时间复杂度的记忆方法。基数排序每一趟都要进行“分配”和“收集”。“分配”需要依次对序列中的每个关键字进行,即需要顺序扫描整个序列,所以有n这一项;“收集”需要依次对每个桶进行,而桶的数量取决于关键字的取值范围,如放数字的桶有10个,放花色的桶有4个等,刚好是ra的值,所以有ra这一项,因此一趟“分配”和“收集”需要的时间为n+ra。整个排序需要多少超的“分配”和“收集”呢?需要d趟,即关键字的关键字位数有几位,就需要几趟。
(4)空间复杂度分析:
每个桶相当于一个队列,需要头尾指针,共rd个桶,所以需要2rd个存放指针的空间,因次是O(rd)
八、各种排序方法比较
类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | ||
最好情况 | 最坏情况 | 平均情况 | 辅助存储 | |||
插入排序 | 直接插入 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
折半插入 | O(nlog2n) | O(n2) | O(n2) | O(1) | 稳定 | |
希尔排序 | O(n) | O(n2) | ~O(n1.3) | O(1) | 不稳定 | |
交换排序 | 冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
快速排序 | O(nlog2n) | O(n2) | O(nlog2n) | O(nlog2n) | 不稳定 | |
选择排序 | 直接选择 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 不稳定 | |
归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 | |
基数排序 | O(n+rd) | O(d*(n+rd)) | O(d*(n+rd)) | O(rd) | 稳定 |