冒泡排序
基本原理:以从小到大排序为例,每次从上到下比较两个相邻的值,如果不是从小到大的顺序则互换这两个值的位置。符合顺序则不变。这样从第一个到最后一个筛选一遍,第一趟排序结束,此时最大的数已经放在了最后面,即已经确定了最大数的位置
此时对前N-1个数重复该过程(因为刚已经确定了第N个位置的数),这一趟结束后确定了第N-2的位置的数。持续该过程。
伪码:
swap()是交换位置的函数。
最开始时P等于最后一个元素的位置,即N-1(整个序列是0-(N-1) )。也就是第一趟是从第一个到第N-1个进行遍历。第二趟是P–,此时P为N-2。第二趟从第一个到第N-2个进行遍历。以此类推。直到P等于0,循环结束
注意如果冒泡排序循环到一半,而此时该序列已经完全有序了,如何检测到该情况并停止排序?
解决方法是对每趟排序加flag,flag初始化为0.只要每趟排序执行一次swap(),就将flag置1.如果某趟排序未执行一次swap(),则flag为0,循环结束。
时间复杂度:
最好的情况:
即一开始所有的数都是排好序的,在执行时只需要从上到下扫描一遍。
最坏情况:
整个都是逆序的,走满N-1趟排序,且每两个数必须做交换。
冒泡排序非常适用于单向链表中的排序
插入排序
就像打牌时摸牌的过程,每次摸到一张牌,就在现有的牌中找到他应该插入的位置,并让其他牌空出该位置,插入。每次插入前手里的牌已经是先前排好顺序的牌。伪码如下:
从i=1,即第一张牌开始循环(因为第0张牌相当于已经在手里了,不用循环)。此时先摸到下一张牌即A[P],将他赋值给Tmp,然后将其与手中现有的牌一一对比,从后往前一直比到手中的第一张牌为止。
比较的时候只要当前手里这张比到的牌比tmp小,就将手里这张牌往后错位,即将A[i-1]放到A[i]。一直重复该过程,直到手里这张比到的牌大于等于tmp,此时i所指向的位置,就是tmp要放的位置。
动图演示见https://www.cnblogs.com/coding-996/p/12275710.html
时间复杂度:
最好情况:一开始就是顺序的,只需要把所有的牌摸到手里即可。
最坏情况:完全逆序。每摸进一张牌都要使前面所有牌往后错一位,一共N张牌。
因为每次手里的牌比当前牌小的时候才要移位,所以如果相等时原位置的牌不动,所以插入排序是稳定的。同理,上面的冒泡排序也是稳定的。
时间复杂度下界
逆序对
每两个相邻元素交换正好消去1个逆序对
设I是原始序列中逆序对的个数,对于插入排序,可以看到排序的时间复杂度不仅和待排序的数目N相关,也和逆序对个数I相关。
存在定理如下
也就是说任意序列中逆序对个数在O(N^2)这一数量级。
所以有另一条定理:
这就意味着要提高算法效率,必须每次消去不止一个逆序对
希尔排序
例如:
先做5-间隔的排序:
在序列中每隔五个选取一个元素,得到一个子序列。如图,得到35,41,81.对这三个数进行排序,并放在相应位置。继续取下一个5间隔的子序列,即94,14,75,将这三个数排序并放到对应位置。直到全部遍历5间隔。
接下来考虑3-间隔每个三个数取一个,得到上图。进行排序,并一直选取。此时大部分已经基本有序。最后进行1-间隔排序。得到结果如下。
希尔排序流程:
定义增量序列,并对每个增量进行该增量间隔的排序,如上例中分别定义5,3,1间隔的排序。这里有一个重要的性质:更小间隔的排序不会打乱上一次更大间隔的排序。如上例中,3间隔的排序后此时该序列按5间隔检查仍然是有序的。
希尔增量序列的选取:
对于原始的希尔排序,最开始的增量取N/2,之后的增量每次取前一次增量的1/2.在循环内部实际执行的是插入排序。伪代码如下:
最坏情况下:按上图排序算法时间复杂度为θ(N^2)
举个坏例子:
这是一个16个数字的序列,首先做8间隔排序,如图,会发现8间隔排序几乎没有改变序列。接下来进行4间隔的,会发现又未改变顺序。2间隔还是一样情况。还是靠最后1-间隔排序。其原因是增量元素不互质
还有许多别的增量序列定义方法:
如hibbard序列和sedgewick序列:
希尔排序的稳定性:不稳定
由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。
选择排序
以从大到小为例,每次找到未排序部分的最小元,并将该最小元放到有序部分的最末位置。伪码如下
在上图的swap()函数中,最坏情况下每个最小元都要换一次,此时一共交换了(N-1)次(初试第一个不用换),所以这里swap()的复杂度是O(N),即是线性的复杂度。但是ScanForMin()每次要遍历查找最小元,这个函数的复杂度也是O(N),加上最外面套的For()循环,所以该函数总体复杂度是**O(N^2)**数量级。
这里如果可以对ScanForMin()函数优化,即快速找到这个最小元,就可以显著降低复杂度。考虑到可以采用最小堆,即堆排序。
堆排序
算法1
BulidHeap(A)通过前面讲的复杂度为O(N)的算法将一个数组调整成为最小堆.通过deleteMin()每次弹出堆顶的最小元,并依次存在临时数组TmpA[i]。最后再把临时数组倒回到原始数组A中。其中每一步的复杂度如图。全部加在一起复杂度一共是O(NlogN)
但是该算法的缺点是需要额外开一个O(N)的空间用来存储,浪费空间,且数组的复制需要时间。
算法2
算法2中并不将其调整为最小堆,而是调整为最大堆。
例如上图,首先将其调整为最大堆。此时d是最大的元素。再将该最大堆调整为最小堆。这里即将最大的值放到末尾位置,也就是把d放到图中[3]的位置。所以把b和d交换。结果如下。
此时d已经放在了其最终位置,就不用再考虑[3]这个元素,继续对剩余节点进行上述操作。对剩余的三个元素继续调整,结果如下
注意:前面讲的堆排序中首元,也就是堆顶元素是不放值的,这里放一个哨兵防治越界。但是这里堆排序是不放哨兵的,堆顶就是最小元。所以这里每个节点和其父母节点的关系就变了。在堆排序中,元素下标从0开始。则对于下标为i的元素,其左、右孩子的下标分别为2i+1,2i+2
真正的堆排序伪代码如下
其核心是调用向下过滤的子函数PercDown(),i对应的是根节点所在位置,N对应当前有多少个元素。从i=N/2开始反复调用就建立起最大堆。然后进行循环。
此时由于最大堆建立结束,数组A中存的是各个节点的下标,所以A[0],也就是根节点中,一定存的是当前最大的元素的下标,而A[i]存的是当前堆的最后一个元素的下标。这里执行Swap(),就是将根节点和当前堆的最后一个元素互换。然后把剩下的元素继续调整为最大堆。调整时以0为根节点,i是当前最大堆的元素个数。
该算法的时间复杂度:
即比NlogN稍微小一些
稳定性:
堆排序是不稳定的
归并排序
核心:有序子列的归并
例子如下:
分别有A,B两个指针指向当前两个序列的第一个元素,C指针指向现在要放的这个元素的位置。这里指针存的都是位置。
这里首先比较A和B指针指向的两个元素哪个更小,并将小的那个元素放在C指针指向的位置。图中这里放入了1.此时指针A++,指向下一个位置,指针C也加一指向下一个位置。继续比较,发现此时B指针指向的2更小,所以放入2.此时指针B++。
如果这两个子列共有N个元素,这一趟归并的复杂度是O(N)
伪码如下:
A是待排的序列,TmpA用于临时存储数组。L指的是要归并的左边的起始位置,也就是上例中的A指针,R是右边的起始位置,上例中的B指针,RightEnd用来存储右边的序列的终点所在的位置。
这里假设左右两个序列是紧密挨着的,那么左边序列终点的位置LeftEnd的值就是右边序列起点R-1。
Tmp相当于上例中的C指针,其初始位置和L相同。比如初始L是0,那么此时临时数组的指针Tmp也为0,表示这里TmpA中也是从第0个元素开始放置。NumElments用来存放归并完之后元素的总个数,也就是最右边终点的位置-左边起点位置+1.
接下来的一个while()循环是归并过程,该循环用来比较左边和右边序列的当前元素哪个更小,如果左边序列当前值更小就把该值放入TmpA中的当前位置,并将两个指针位置++。反之亦然。这里循环持续进行的条件是L小于左边的终点,并且R小于右边的终点.即左右两个序列都不为空,即都还没被循环结束。一旦有一个序列循环结束了,就退出这个归并过程。
跳出这个用来归并的While()循环后,一种可能的情况是两个待排的序列都空了,另一种可能是其中一个空了,另一个序列没空。这时将没空的序列剩下的元素直接导入进TmpA即可。
如图中最后两个while()循环,前一个循环表示左边的循环剩下了元素未循环完,就直接把左边剩下的放入TmpA。右边反之亦然。
最后要把TmpA中的结果导回到A中。此时存放整个序列的起始位置L变量已经变动了,但是存放右边终点的RightEnd没变。所以根据右边终点倒着把TmpA导入A中。每次RightEnd–,一共要减元素的个数次,也就是前面计算的NumElments
归并排序在具体实现时有两种策略,其中一种是递归
采用递归算法实现归并
归并中的递归是典型的"分而治之"的应用。也就是先把整个序列一分为二,分为左右两边,再递归的把左右两边排好序。最后将其归并到一个完整的数组中。
伪代码如下:
L是当前待排序列最左边的位置,RightEnd是当前待排最右边的位置。还需要一个变量Center记录整个序列最中间的位置。
先算出中点的位置Center,再递归的把从L到Center的左半边递归排序。再从Center+1到RightEnd右半边进行递归排序。此时调用Merge()函数完成归并并将结果存在A中。这里TmpA仍旧是存储临时数组。
这里的if()用于判断执行递归的条件。也就是当待排的序列中还有元素的时候执行。递归到最后L=RightEnd,此时排到只剩这一个元素,所以直接将该元素返回即可。
时间复杂度分析:
假设整个排序用时T(N),那么递归的解决左半边用时就是T(N/2),因为待排的序列规模减了一半。右半边同理。然后执行Merge()归并,就如上面分析的,这一步复杂度是O(N).总的复杂度可以推出是O(NlogN)
注意这里O(NlogN) 不分最好和最坏情况,任何情况下复杂度都是这个值。
稳定性:
归并排序是稳定的
稳定,表示有相同的元素 排序之后 本来在前面的还是在前面
比如 123453678 ,排序之后123345678这里第一个3还是123453678里的第一个3 123345这里的第二个3还算本来123453678中的第二个3。归并排序中如果说稳定性破坏,那只能是在合并的过程中。而在合并的过程中,2个元素如果相等我们始终会先将左边子数组的元素先放入原数组当中,这样就不会破坏稳定性。如我们将{1,3,5,3,6,9}排序,在{1,3,5}和{3,6,9}合并的过程中,左边的元素3先放入数组中。
对上述归并排序代码的改进
由于上述代码的输入接口要求输入的参数很多,对用户不够友好,所以再写一个统一的函数接口。这里只需要传进来原始数组A[]和原始的个数N即可。伪代码如下:
这里首先申请和声明TmpA。之后执行Msort(),这里传入的参数,除了A和TmpA,0表示待排序列最左边的位置,N-1表示待排序列最右边的位置。最后记得释放临时数组的空间。
值得注意的是,为什么TmpA要在这里声明,又传到Msort()中,为什么不直接在Msort()中声明呢?
如果将申请TmpA空间的Melloc()放在Msort()中,因为是递归的调用,所以就会出现反复申请空间,反复释放的情况,而且每次递归都会调用Msort(),就要多次申请空间,就会出现如下图的情况。图中黑框是每次申请又释放的空间。每一个小黑框表示每一次申请又释放的空间。这样做的话就会大大增加算法的空间复杂度。
采用非递归算法实现归并
其基本思路是:假设一开始有N个子序列,每个子序列都只有一个元素,每次把相邻的两个子序列做一次归并,如下图第二行所示,就形成了若干个有序的子序列,每个子序列包含两个元素,这两个元素之间是有序的。持续进行上述过程,直到得到完整序列。
这种操作所需要的额外空间复杂度是O(N)
因为可以第一次归并的结果存入临时数组,此时对临时数组进行第二次归并,将该结果存到A中。再进行第三次归并,存入临时数组中,这样来回导入。这样导入到最后,如果最后一次归并刚好导入到A中,则程序结束。如果最后一次归并到了TmpA中,要再加一步,将其导入到A中。
伪代码如下:
首先写其核心步骤,即归并函数Merge_pass(),如下图
首先看该函数传入的参数。N是整个待排序列的长度,Length是当前有序子列的长度,也就是说Length在最开始时等于1,因为初始时假设每个单元自己就是一个有序子列。在后续每次执行该函数时Length会加倍。
接下来图中for()循环执行的就是从左到右,依次对成对的两个有序子列调用归并函数Merge1().这里的参数i是每次序列最左边的位置,i+length是这两个子列中第二个子列的初始位置,第二个子列的终止位置是i+2Length-1。
每一轮循环后,I+=2Length,这么做的目的是跳过当前这两段已经排过的序列,寻找下一对待排序列。的这里的Merge1()函数和前述的Merge()函数类似,但是有一个地方不一样。原始的Merge()在归并结束后结果一定放在A中,即会在最后把结果从TmpA导到A中。但是Merge1()中不进行这一步操作,所以Merge1()是把A中的元素归并到TmpA中,即这里有序的数组是放在TmpA中的。
注意:可以看到上图中,for()循环中判断结束的条件并不是i<=N,而是i<=N-2Length,这是因为前面是成对进行归并的,正好归并完成的前提条件是序列有偶数个,才都能成对进行上述操作。而如果是奇数个就会多出一个序列,这样还可能会出现归并到最后,最后两段序列的长度可能不同。所以这个for()循环只处理到倒数第二对序列,对于最后一对序列要单独拿出来操作。
这里首先用if()判断,如果i+Length<N,说明剩下的不止一个子列,有两个子列。所以继续对这两个子列进行Merge1()归并,注意到这里传入Merge1()的最后一个参数是N-1,是因为无论如何最后一个字列的末尾一定是第N-1个元素。
如果该if()条件不成立,意味着此时只剩了一个子列,所以只需将该子列直接导入TmpA即可。
这就完成了一趟归并的过程。
外部接口部分
这里首先初始化子列的长度为1.并声明了和原始数组等长的TmpA。判断空间是否足够,即TmpA是否申请成功。
接下来将这里的Length带入Merge_pass()中。执行完一次后,Length要乘2.再继续调用Merge_pass().
这里可以看到图中两次Merge_pass()传参的A和TmpA位置不同。即如上面介绍算法原理时所述,第一次对A归并,结果存在TmpA中。接下来对TmpA归并,结果又存在A中。
这样每次循环都做两步Merge_pass(),能够保证最终的结果一定能够导入在A中而不是TmpA中。假设哪一次循环中执行到while()中第一个Merge_pass()就已经有序了,但这无妨,此时仍会执while()中第二个Merge_pass()将其原封不用再导回到A中。这也是为什么前面Merge_pass()中的Merge1()要进行改动,因为这里每次调用Merge_pass()将结果存入的位置都不同,有时是A有时是TmpA,而存在哪要由Merge_pass()外部决定。
while()循环的终止条件是Length>=N
稳定性判别:
这种归并排序方法是稳定的。
注意:归并排序一般不用于内排序,即所有元素都必须在内存中完成操作。因为他要额外申请空间。但是归并排序在外排序中很好用。