第七章:排序
1.排序的基本概念
排序
重新排列表中的元素,使表中的元素满足按关键字递增或递减
排序算法的稳定性
若待排序表中有两个元素Ri
和Rj
,其对应的关键字ki=kj
,且在排序前Ri
在Rj
前面,若使用某排序算法后,Ri
仍然在Rj
前面。则称这个排序算法是稳定的
,否则称排序算法不稳定
。
注意:算法的稳定性是算法的性质,并不能衡量一个算法的优劣
内部排序
指在排序期间元素全部存放在内存中的排序
内部排序:
插入排序(直接插入排序、折半插入排序、希尔排序)、交换排序(冒泡排序、快速排序)、选择排序(简单选择排序、堆排序)、归并排序;基数排序。(前8种基于元素移动和比较)
衡量一个内部排序算法优劣程度的标准:时空复杂度决定内部排序算法的性能。
外部排序
指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间进行移动‘
2.插入排序
2.1.直接插入排序
插入排序
每次将一个待排序的序列插入到一个前面已排好序的子序列当中
直接插入排序
空间复杂度O(1)
- 初始L[1]是一个已经排好序的子序列
- 对于元素
L(i)(L(2)~L(n))
插入到前面已经排好序的子序列当中: - 1)查找出L(i)在L[1….i-1]中的插入位置k
- 2)将L[k….i-1]中的所有元素全部后移一个位置
- 3)将L(i)复制到L(k)
代码实现:
1 | //注意我们数组下标为0的位置不存储值 |
时间复杂度:(最好、平均、最坏)
直接插入排序是稳定的!
因为我们判断是否移动是小于号,而等于的情况我们是不移动的,值得相对位置不变。它适用于链式存储和顺序存储。
2.2.折半插入排序
在直接插入排序中我们是边比较边移动进行排序的,这个过程其实就是顺序查找的过程,查找适合我们插入的位置,我们之前学过学过比顺序查找效率更高的方式:折半查找,将这个思想应用到折半插入排序中
代码实现:(得到一个递增序列)
1 | void BInsertSort(ElemType A[],int n)//n是数组大小 |
算法思路分析:对于折半查找部分中wile循环中最后一次循环肯定是low=high,此时mid=low=high,其中的if判断有两种情况:
第一种A[mid].key<=A[0].key,此时说明要插入的元素大于(等于)当前mid的元素,此时需要将该元素插入到当前指向元素的后面,我们修改low的值为mid+1;此时不需要移动元素直接A[high+1]=A[0]。因为我们插入high后面high+1既可。
第二种情况A[mid].key>A[0].key,此时说明要插入的元素小于当前mid的元素,此时需要移动元素,即插入到mid元素前面(其实就是插入到mid位置),我们让high=mid-1;进行元素的移动将mid元素及其后面的元素都向后移动一个位置。因为刚才high=mid-1,这里再+1可以指到high位置。移动元素结束即可赋值。
时间复杂度:O(n^2) 折半插入排序是稳定的!,只适用与顺序存储
。
2.3.希尔排序
希尔排序又叫缩小增量排序。基本思想:先将排序表分割成d个形如L[i,i+d,i+2d,...,i+kd]
的特殊子表,分别进行直接插入排序,当整个表中的元素已呈基本有序时,再对全体记录进行一次直接插入排序。
上面三个图基本演示了希尔排序的过程,其中第三张图最后进行一次直接插入排序就得到我们要的结果,每次分组我们都会缩小这个分组的步长,直到步长为1时的排序结束。
步长的第一次选取:d1=n/2(取下界) di+1=di/2(取下界)
直到最后一个dk=1
代码实现:(得到一个升序序列)
1 | void ShellSort(ElemType A[],int n){ |
算法思路分析:第一个for
循环就是逐步缩小我们的步长dk
的一个过程,初始化直接时表长n/2
。第二个for循环就是对当前步长dk
所分组的每一组进行直接插入排序。这里算法代码编写的和我们描述的过程是不同的,我们描述的时候讲分别对每个组进行直接插入排序,但是这里代码第二个for循环中初始化i=dk+1
,即为当前组的下一个元素,i<=n
,每次是i++
,而不是i+=dk
,所以这里代码编写的相当于多个组同时进行直接插入排序。接着进行if判断,判断改组的当前元素是否小于他前面的那个元素,如果小于说明需要修改位置(因为我们要构建一个升序序列)。同样下面的A[0]=A[i]
哨兵暂存待插入的值。第三个for循环初始化j=i-dk
这里就是为了取到刚刚判断的那个i-dk
的下标值,然后for循环的判断有j>0和A[0].key<A[j].key
因为j-=dk
可能或取到负值,这个for循环第一次肯定会执行循环体,因为前面的if已经判断过了,循环体即为交换刚刚两个互相比较的值,其中小的值已经暂存在A[0]
,此时修改 j 的值继续比较当前组的是否有比A[0]值大的
我们这个 j 是向前走的,而 A[0]
存的是后面的值,我们这样比较是看后面前面是不是还有更大的值。有的话进行交换,直到没有,我们最后将A[0]
的值赋值到当前的 j+dk
的位置,因为只要能进入第三个for循环,就会进行A[j+dk]=A[j];j的值都会减去dk,A[j]的值赋给了A[j+dk],所以最后我们要修改A[j]的值,但它减了dk,所以最后要加上
。第二个for循环的第一次循环结束后,i++
相当于开始进行下一组的排序。
最坏情况下时间复杂度:O(n^2) ,一般是O(n^1.3),是一个不稳定算法。只适用于顺序存储。空间复杂度:O(1)
插入排序总结
算法名称 | 时间复杂度 | 空间复杂度 | 是否稳定 | 适用于 |
---|---|---|---|---|
直接插入排序 | O(n^2) | O(1) | 稳定 | 顺序存储和链式存储 |
折半插入排序 | O(n^2) | O(1) | 稳定 | 顺序存储 |
希尔排序 | O(n^2) | O(1) | 不稳定 | 顺序存储 |
3.交换排序
3.1.冒泡排序
基本思想:假设待排序表长为n,从后往前(从前往后),两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]
),则交换他们直到序列比较结束。
第一次循环从前往后进行比较,我们发现最总我们将最大的值9放到了最后面的位置,因为我们的比较的时候如果前面的值大于后面的值就会交换位置,所以出现了这种情况。
所以再冒泡排序中有这样的特点:一次冒泡会将一个元素放置到它最终的位置上 。也就是一次冒泡可以确定一个元素的位置,就如上图,接下来我们只需要对9元素前面的位置进行冒泡就又可以确定一个元素的位置,然后依次(n-1次)冒泡即可将一个乱序序列,排成一个递增的序列。
代码实现:
1 | void BubbleSort(ElemType A[],int n){ |
时间复杂度(最好、平均、最坏):O(n)、O(n^2)、O(n^2),空间复杂度:O(1),是稳定的算法。适用于顺序存储和链式存储。
3.2.快速排序
基本思想:在待排序表L[1....n]
中任取一个元素pivot
作为基准,通过一趟排序将待排序表划分为具有如下特点的两部分:(这里我们默认选取第一个元素作为基准)
这里第一次排序(一趟Partition)之后pivot左侧的元素全是小于pivot的,右侧的元素全是大于pivot的
我们可以发现一次排序后会将一个元素pivot放置到它的最终位置上 ,因为pivot左侧的元素全是小于pivot的,右侧的元素全是大于pivot的。
接下来对pivot前面的这部分在进行一次快速排序,后面的部分再进行一次快速排序。同样可以得到两个元素放置到它们最终的位置上。让后继续…..【快速排序的思路】
Partition的基本思路:
初始化标记low为划分部分的第一个元素的位置,high为最后一个元素的位置,然后不断地移动两标记交换位置:
high向前移动找到第一个比pivot小的元素
low向后移动找到第一个比pivot大的元素
交换当前两个位置的元素
继续移动标记,执行 1、2、3 的过程,直到low大于等于high为止
Partition代码实现:
1 | //这里代码返回整型,是因为我们要返回划分的元素的位置的下标,我们就可以通过这个位置将序列划为两部分 |
第一次进行Partition:
接下来需要利用返回的这个基准的下标将数组划分为两部分,分别进行Partition
快速排序代码实现
1 | //采用递归的方式调用QuickSort,每次判断是否进行Partition |
可以发现 快速排序是一个不稳定 的算法。时间复杂度:O(high-low+1)
快速排序的执行过程:
我们可以发现整个过程类似一颗树,也就是说如果我们基准选择的好,那么这个树的深度就比较浅,相对的递归次数就比较少,那对应工作栈的长度就越小,空间利用就越小。所以它的最好、平均空间复杂度
如果一个序列初始基本有序或逆序的情况如上,则最坏空间复杂度为O(n)。最坏的时间复杂度O(n^2)
交换排序总结
算法 | 时间复杂度 | 空间复杂度 | 是否稳定 | 适用于 |
---|---|---|---|---|
冒泡排序 | O(n^2) | O(1) | 稳定 | 顺序存储和链式存储 |
快速排序 | O(nlog2n) | O(log2n) | 不稳定 | 顺序存储(链式存储) |
4.选择排序
4.1.简单选择排序
基本思想:每一趟在后面n-i+1(i=1,2…n-1)个待排序元素中选取关键字最小的元素,作为有序子序列的第i个元素,直到n-1趟做完,待排序元素只剩下1个。
综上:一趟排序会将一个元素放置在最终的位置上
代码实现
1 | //循环找比当前最小值后面更小的值进行交换 |
选择排序是不稳定的,另外根据算法我们可以看无论初始序列是什么样的我们都要进行逐个遍历,所以时间复杂度是O(n^2),与初始序列无关。空间复杂度为O(1),适用于顺序存储和链式存储。
4.2.堆排序
什么是堆?
堆是一棵完全二叉树,而且满足任何一个非叶结点的值都**不大于(或不小于)**其左右孩子结点的值。
如果是每个结点的值都不小于它的左右孩子结点的值,则称为大顶堆。
如果是每个结点的值都不大于它的左右孩子结点的值,则称为小顶堆。
什么是堆排序?
我们知道对于一个堆来说,它的根结点是整个堆中所有结点的值的最大值(顶堆或者最小值(小顶堆)所以堆排序的思想就是每次将无序序列调节成一个堆,然后从堆中选择堆顶元素的值,这个值加入有序序列,无序序列减少一个,再反复调节无序序列,直到所有关键字都加入到有序序列。
所以堆排序最重要的操作就是将无序序列调节成一个堆。
12 52 19 45 23 45 92 (以大顶堆排序为例),首先排成一个完全二叉树序列
①建堆对初始序列的完全二叉树调整成一个大顶堆调整方法:二叉树由下至上由右至左(数组的下标由大到小)。检查每个结点是否满足大顶堆的要求,如果不满足进行调整。
45 23 45 92 四个结点都是叶子结点,不需要调整
19<45<92,所以要用92和19进行交换
52>45且>23 不需要调整
12<52<92 要用92交换12
12<19<45 要用45和12交换
到这里就建好了一个大顶堆
②将堆顶结点和最后一个结点19交换,也就是将最大值92移动到数组的最末尾,有序序列中加入了结点92,无序序列减少结点92,到这里堆排序的第一趟完成。【此时二叉树不满足堆的性质了,需要调堆】
③调堆:调整二叉树的结点使得满足大顶堆的要求。调整方法和建堆时一样。
- 45>12不需要调整,52也不需要
- 19<45<52需要将结点19和52交换
- 19<23<45需要将结点19和45交换
- 到这里又调成了一个大顶堆类似之前的操作,选出根结点52作为有序序列的第二个数值
二叉树性质5:
对完全二叉树按从上到下、从左到右的顺序依次编号1,2…,N,则有以下关系
①当i>1
时,结点i
的双亲结点编号为i/2
,即当i
为偶数时,其双亲结点的编号为i/2
。它是双亲结点的左孩子,当i
为奇数时,其双亲结点的编号为(i-1)/2
,它是双亲结点的右孩子。
②当2i≤N
时,结点i
的左孩子编号为2i
,否则无左孩子。
③当2i+1≤N
时,结点i
的右孩子编号为2i+1
,否则无右孩子。
设最后一个结点编号为N,N等于二叉树中结点数量,它肯定是叶子结点。所以 第一个可能需要调整的非叶结点的下标为N/2 ,从它的左孩子开始检查,它的左孩子下标为*N/2 2,如果发生交换,下一轮检查交换过的结点的左右孩子
代码实现
1 | //建堆代码 |
空间复杂度:堆排序只需在交换节点的时候需要额外存储空间来辅佐,所以空间复杂度为O(1)
时间复杂度:堆排序的总时间可以分为①建堆部分+②n-1次向下调整堆=O(n)+=
稳定性:不稳定
5.归并排序
假定待排序表含有n个记录,则可以看成是n个有序的子表,每个子表长度为1,然后两两归并,得到n/2 个长度为2或1的有序表;再两两归并,..…如此重复,直到合并成一个长度为n的有序表为止,这种排序方法称为2-路归并排序。
例如:49 38 65 97 76 13 27
①首先将整个序列的每个关键字看成一个单独的有序的子序列
②两两归并,49和38归并成{38 49},65和97归并成(65 97,}76和13归并成(13 76},27没有归并对象
③两两归并,{38 49}和{65 97归并成{38 49 65 97},{13 76}和27归并成{13 27 76}
④两两归并,{38 49 65 97}和{13 27 76}归并成{13 27 38 49 65 76 97}
更据这个排序方法可以看出相当于使用了分治和递归的方法
代码实现
1 | ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType));//辅助数组B(这里采用动态分配内存) |
时间复杂度:一共n个元素
Merge第①趟:子序列长度为2.所以有n/2对子序列,每个子序列需要执行1次 Merge 。Merge时间复杂度为子序列长度之和,即2。所以第①趟 merge的时间复杂度为(n/2*2)=0(n)
第②趟 Merge:子序列长度为4,所以有n/4对子序列,每个子序列需要执行1次 Merge, Merge时间复杂度为子序列长度之和即4所以第②趟 merge的时间复杂度为O(n/4*4)=(n)
第k趟归并:子序列长度为2^k,所有有n/2^k对子序列。
当n/2^k=1时,k=log2n,此时需要归并的两个子序列长度为n/2,只需要一次 Merge就能实现整个序列有序。所以一共执行了k=log2n趟排序,每趟排序的时间复杂度都是(n),所以整个归并排序的时间复杂度为O(nlog2n)
空间复杂度:因为需要将这个待排序序列转存到一个数组,所以需要额外开辟大小为n的存储空间,即空间复杂度为O(n)
稳定性:稳定的
6.基数排序
基数排序(也叫桶排序)**是一种很特别的排序方法,它不是基于比较进行排序的,而是采用多关键字排序思想(即基于关键字各位的大小进行排序的),借助“分配”和“收集”两种操作对单逻辑关键字进行排序。基数排序又分为最高位优先(MSD)排序和最低位优先(LSD)**排序。
例子:53,3,542,748,14,214,154,63,616
首先补充位数:053,003,542,748,014,214,154,063,616
桶实际是一个队列,先进先出(从桶的上面进,下面出)
第一次”分配”(队列从上入队)(我们这里采用最低位优先:即关键字补充后的个位进行比较,然后各位数对应同的序号,如果个位数为3,则进入桶3。按照依次进队)
第一次”收集”(队列从下出队)(从桶0到桶9依次出队,此时可以看出每个关键字的个位数依次递增)
第二次”分配”(队列从上入队)(第二次分配很显然使用关键字的十位数字进入桶,同样是依次入桶)
第二次”收集”(队列从下出队)(从桶0到桶9依次出队,此时可以看出每个关键字的十位数依次递增)
003 014 214 616 542 748 154 053 063
第三次”分配”(队列从上入队)
第三次”收集”(队列从下出队)(从桶0到桶9依次出队,此时可以看出每个关键字的百位数依次递增)
003 014 053 063 154 214 542 616 748
此时发现由于关键字现在三个位数都排列有序,所以整个关键字序列都排列有序。
关键字数量为n,关键字的位数为d,比如748 d=3,r为关键字的基的个数,就是组成关键字的数据的种类,比如十进制数字一共有0至9共10个数字,即r=10
空间复杂度:需要开辟关键字基的个数个队列,所以空间复杂度为O(r)
时间复杂度:需要进行关键字位数d次”分配”和”收集”,一次分配需要将n个关键字放进各个队列中,一次收集需要将r个桶都收集一遍,所以一次分配和一次收集时间复杂度为O(n+r)。d次就需要O(d(n+r))的时间复杂度。
稳定性:由于是队列,先进先出的性质,所以在分配的时候是按照先后顺序分配,也就是稳定的,所以收集的时候也是保持稳定的。即基数排序是稳定的排序算法。
最高位优先则是从最高位开:例子:53,3,542,748,14,214,154,63,616
其实我们这里可以对桶中关键字个数不为1的桶递归排序
首先我们现在针对桶0进行排序:即同样拿出10个桶对桶中关键字的次高位进行分配
同样看有没有关键字个数不为1的桶。此时各个桶中都只有一个关键字,递归结束,收集各桶返回到上一层。此时桶中是这个样子。
收集各桶:得到有序序列003 014 053 063 154 214 542 616 748
7.理木客
数据结构相关知识,公众号理木客同步更新中,欢迎关注!到这里数据结构相关知识全部更新完毕!!