排序(数据结构)

排序:排序就是将一些无序的元素,排列成有序。如果一部分元素值相同,那么如果排序后不改变他们原本的相对位置,就称这是稳定的排序,否则称这个排序为不稳定的。

关于稳定性的判断,我个人认为:排序万变不离其宗,都需要一个判定手段,通过这个判定手段来判断这个元素应该放在什么位置,而在我目前的认知下,这些判定手段均是“<”或者“>”,而没有“<=”或“>=”,那么我们就可以根据代码的具体实现,举一个例子来判断此排序是否稳定。

一、内部排序

1.插入排序

        插入排序就是后面的元素与前面的元素对比,然后插入相应的位置,分为直接插入排序和希尔排序,希尔排序是直接插入排序的升级版。

1.1直接插入排序

        直接插入排序是指从第一个元素开始,比较第一个元素后面的那个元素,如果无序,则令这两个元素有序,然后第三个元素与前面个排好序的元素相比较,如果无序,则排序,依次向后遍历,直到整个数组有序。

        代码实现也很简单,第一种方法是使用一个交换变量来交换待排元素。第二种方法是利用数组首元素下标为0的特点,将第一个位置空出来,用来为交换元素提供空间。

        知道了直接插入排序的代码实现,那么空间复杂度也就很简单了。由于我们只是利用了1个辅助空位来交换元素,故这种排序的代码实现的空间复杂度为O(1)。时间复杂度分为最好和最坏两种情况,最好情况:如果这个数组原本就是有序的,那么我们每次的对比只需要令待排关键字与他的前一个关键字比较即可,然后移动一次元素,这样整个表遍历下来,所需要O(n)的时间复杂度,最坏情况:如果数组原本是倒序,我们像将其变为正序,那么我们第一次比较关键字需要比较一次,移动一次元素,第二次需要比较两次,最坏需要移动2次元素,第n次需要比较n次,最坏需要移动n次元素。这样一来整体的时间复杂度为O(n^2)。平均时间复杂度为O(n^2)。

        为了一定程度上提高我们的算法效率,我们在比较关键字的时候,可以用折半查找来找到我们待排关键字所应该在的位置,这样一来我们只需要比较log2n次就可以找到位置,但是移动关键字的最坏情况并没有改变,那么时间复杂度由于移动关键字的存在,平均时间复杂度依然是O(n^2),此种排序为稳定的。

1.2希尔排序

        由于直接插入排序的这种类似于暴力枚举的无脑性,导致他的时间复杂度很高,而希尔排序是基于直接插入排序的优化,那么希尔排序的实现方法为:从第一个关键字起,不需要用后一个关键字与他进行比较,而是利用一个特定的跃变值来找到下一个与将要他进行比较的关键字

        例如这个跃变值为4,那么第一个关键字就要与第五个关键字相比,而第一个和第五个关键字排好序后,还要与第九个关键字相比;第二个元素要与第六个元素相比,而第二个元素与第六个元素排好序后,还要与第十个关键字相比,这样类推下去,所有的排好序后,我们令跃变值减一,直到最终达到直接插入排序,因为直接插入排序不过就是跃变值为1的希尔排序。此种排序为不稳定的。

2.交换排序

2.1.冒泡排序

        冒泡排序是一种每一趟排序都可以使数组中最大的关键字像泡泡一样冒到最后面,或者使最小的关键字去最前面。那么是如何实现这种排序的呢?

        首先我们需要定义一个辅助变量用来交换关键字,这也是为什么冒泡排序是交换排序的一种,假设我们要每趟排序要将最大的关键字往后冒:我们先令第一个关键字和第二个关键字比较,使更大的交换到后面,再让第二个关键字与第三个关键字比较,使更大的交换到后面,以此类推,直到数组全部遍历完毕,此时最大的关键字也成功的被放在了最后的位置,然后开始第二趟排序。

        那么代码如何实现呢?首先我们定义一个循环,由于每趟排序都可以使一个关键字排好序,所以循环遍历的长度每次减1,然后在循环中嵌套一个循环,以便于遍历数组。然后在内部循环中设置一个交换的代码。最后再设置一个布尔型的变量:如果本次遍历数组的过程中没有发生交换,则说明已经排好序,跳出循环。

        由于我们只定义了一个辅助变量用来交换关键字,所以空间复杂度为O(1)。在最好的情况下,数组原本就有序,故只需要一次遍历即可,时间复杂度为O(n),在最坏的情况下,直到冒泡到最后两个关键字,才能排好序,此时由于循环遍历了n次,遍历长度从n到1,故最坏时间复杂度为O(n^2),平均时间复杂度为O(n^2)。此种排序为稳定的。

2.2.快速排序

        快速排序人如其名,是这些排序中效率最高的。快排的实现方式为:每次选定表头的关键字,让其成为一个中间值,在向后遍历的过程中比该关键字小的全部放在此关键字的前面,比该关键字大的全部放在此关键字的后面。然后再对此关键字前面的数组和后面的数组递归的进行快排,这样每次寻找中间值的排序叫做快速排序。接下来让我们看看如何代码实现。

        首先我们定义一个快速排序的函数(传输参数为:数组A,low,high),然后在这个函数中,先定义一个寻找中间值所在位置的函数,然后再次调用快速排序的函数(传输参数为数组A,low,中间值位置-1)这样做的目的是递归的对左子表快速排序,然后再调用快速排序函数(传输参数为数组A,中间值位置+1,high)。

        快速排序,由于我们用到了函数的递归调用,那么就要在内存中开辟一个函数调用栈,最坏的情况:原本数组是逆序,每次的关键字都是最大的,那么就需要调用n次函数,空间复杂度为O(n),但是如果每次的关键字都为中间元素,那么快排就像一颗平衡二叉树一样,树高为log2n,空间复杂度也为O(log2n)。对于时间复杂度而言,我们可以这样分析:每次寻找中间值所需要O(1)的时间,而第一趟快排的过程,的时间复杂度为O(n)的时间,而一共要进行递归层数那么多趟数的快速排序,故时间复杂度最好的情况为O(nlog2n),最坏为O(n^2),而快排与其他排序不同,快排的最坏情况发生的概率微乎其微,故我们可以用最好的时间复杂度来作为其平均时间复杂度。此种排序为不稳定的。

3.选择排序

3.1简单选择排序

        所谓简单选择排序,就是遍历一遍数组,选择出最小的关键字与数组的首位的关键字进行交换。然后再次遍历除去首位关键字的数组,再次交换以此类推。

        代码实现:首先定义一个循环用来指定遍历次数,然后在循环中嵌套一个循环用来选择最小的元素(每次循环需要将遍历的初始位置+1)。

        由于我们利用了一个辅助变量用来交换关键字,所以空间复杂度为O(1)。由于我们需要遍历n次数组,每次遍历长度为 n-i ,故时间复杂度为O(n^2)。这种排序为不稳定的。                             

3.2.堆排序

        堆排序是建立在堆这种数据结构上的一种排序,堆又分为大根堆和小根堆,大根堆是用来正序排列的,小根堆是用来倒叙排列的,这里拿大根堆举例。

        如何建立大根堆:堆就相当于一个完全二叉树一样,堆分为根结点、左子树和右子树。而大根堆就相当于是根结点关键字大于左右子树的关键字的二叉树。而建立一个大根堆无非就是插入关键字的操作,而插入关键字所需要用到的基本操作就是“上升”以及“下坠”。假设我们已经有了一个大根堆,那么此时我们要向大根堆的叶结点上插入一个关键字,那么此关键字就要个父节点比较,如果比父节点大,那么他就要与父节点进行交换,这就是上升。而如果我们要使一个关键字去代替大根堆中的分支结点,那么此时我们就要与其父节点以及孩子结点进行比较,如果比父节点大就要与父节点交换,如果比孩子结点小,那么就要与更小的那个孩子结点进行交换。

        那么建立好大根堆之后,如何利用大根堆进行排序呢?此时我们有一个无序的数组,且我们用这个数组建立了一个大根堆,那么此时,我们只需要让大根堆的根结点关键字与数组中的最后一个关键字交换即可,交换完后一定会破坏原有大根堆的特性,那么此时我们就需要让此根结点下坠。然后递归的再次令根结点与数组的倒数第二个关键字交换,类推下去就会得到一个正序的数组。那么小根堆除了最终得到倒序的数组,其他与大根堆类似。

        这种算法由于我们只是用了一个辅助变量用来让大根堆与数组最后一个关键字进行交换,所以空间复杂度为O(1)。每次我们令根结点与数组的最后一个关键字交换,这个过程时间复杂度为O(1),但是交换完后,新根结点下坠的过程就要取决于树的高度了,由于我们在建立大根堆的时候就已经令其是完全二叉树了,所以其高度为log2n。建立堆的时间为O(n),之后每次排序又要下坠n-1次,每次下坠都消耗log2n的时间。故其时间复杂度为O(nlog2n)。

        这种排序为不稳定的。

4.归并排序

        归并排序是一种能让n串有序的数组合并成一串有序的数组的排序。这种排序被广泛应用于一个很大的数组中,找到n串有序的数组进行归并排序,使这个大数组局部有序。

        归并排序一般考察二路归并,这里以二路归并举例,来实现归并排序,其他的四路归并,八路归并类似于二路归并。

        实现方法:首先建立一个能够容纳将要归并排序的两串有序的数组中所有关键字的大数组,然后依次比较两个数组中最小的关键字,然后将关键字复制到新的大数组中,然后再次寻找第二小的关键字写到大数组中上一个的关键字的后面。

        代码实现:建立两个指针指向两个有序的小数组的首位,若A数组的首位更小,那么就让A指针所指的关键字复制到建立好的大数组中,然后令A指针后移,依次类推。

        但是这种排序似乎只能排这种已经排好序的两个数组,这样的话,这个归并排序是不是太弱鸡了啊,排人家排好序的。

        OK,现在我们就见识一下真正的归并排序,我们设想一下:是不是任意一个单独的关键字,都可以被看成是一个排好序的数组?那么如果给出任意一串无序的数组,数组中共有n个关键字,那此时这个数组就可以看成是一个有n个排好序的小数组组成。那二路归并的效果就是使任意两个排好序的数组合并为一个更大的排好序的数组,那么我们对这个无序数组进行第一趟二路排序的时候,原本由n个有序的小数组组成的数组就变成了n/2个有序的小数组组成的数组,然后再进行第二趟二路归并,就可以使这个数组变成由n/4个有序的小数组组成的大数组,这样类推下去,最后就会使整个数组有序。

        你们发没发现这个过程有点像是n个结点变成n/2个结点,然后n/2个结点变成n/4个结点,n/4个结点变成n/8个结点……这个不就是一颗倒着的平衡二叉树吗,那么我们就右可以利用平衡二叉树的性质了。

        代码实现:这次我们就要用到递归了,创建一个函数用来将数组分成两段,然后在函数中继续调用这个函数,直到整个数组被分为n个由1个关键字数组形成的数组,然后再用前面封装好的二路排序的函数,对这n个小数组进行排序即可。

        由于我们建立了一个新的大数组,故归并排序的空间复杂度为n个小数组空间相加,数量级为O(n),递归过程中的递归次数取决于树高,而我们知道平衡二叉树的树高为log2n,空间消耗为O(log2n),故整体空间复杂度为O(log2n)。我们函数递归的时间开销为O(log2n),指针的后移过程所需要O(n),故时间复杂度为O(n*log2n)。这种排序为稳定的。

5.基数排序

        基数排序没那么重要,我就说一下简单实现逻辑,如果是10进制数,那么我们就建立一个0~9的数组,每个数记录关键字中的基数,比如第一趟基数排序,按照关键字的个位数将关键字放在数组中,如果有相同的关键字,就按照邻接表的形式放在相同的位置上。第一趟排好序后,这些无序的数就已经按照个位有序的形式排列了,第二趟排十位数,第三趟排百位数以此类推,最终就可以将整个数列排好序。

二、外部排序

        外部排序是指我们要将磁盘中的一些数据排好序,而磁盘是分成磁盘块来储存数据的,每个磁盘块中存放着一些数据,外部排序的目的就是将整个磁盘中的数据变得有序。

1.归并排序在外部排序中的应用

        知道了外部排序的目的,那么根据归并排序的原理,只要这些磁盘块中的数据是有序的,我们就可以根据归并排序将整个磁盘中的数据排好序。那么如何让每个磁盘块中的数据都有序呢?

        我们都知道,排序只能在内存中进行,这里以二路归并举例,那么我们就要在内存中建立两个输入缓冲区(输入缓冲区的大小与一个磁盘块的大小一致),以及一个输出缓冲区。首先我们要将前两个磁盘块中的内容分别读入建立好的两个输入缓冲区中,然后选择出两个输入缓冲区中最小的元素放入输出缓冲区,然后继续选择,直到输出缓冲区满了,此时就需要将输出缓冲区的内容重新写入第一个磁盘块中(注:这里的磁盘块在逻辑上是原先的磁盘块,但是在物理上两个磁盘块不在同一块空间中。)然后继续向输出缓冲区写入内容,直到某个输入缓冲区的数据用光,此时我们就要将第三个磁盘块中的内容读入输入缓冲区。这样类推下去就可以完成所有磁盘块的排序,这样就建立好了初始的归并段。此时的归并段为原先磁盘块的二分之一。

        建立好了归并段后,接下来就是归n并操作,然后再次将第一个归并段中第一个磁盘块,与第二个归并段中第一个磁盘块放入两个输入缓冲区中进行二路归并,这样操作下来就可以令前两个归并段合并为一个更大的归并段,这样最终就可以使整个磁盘数据有序。

        知道了归并排序在外部排序中的应用后,我们还要思考,如何去优化排序时间。要直到读写磁盘的时间开销是非常大的,如果这样二路归并下去,树的高度为log2n。那么时间开销很大,所以我们采用多路归并就可以解决读写次数多的问题,4路归并树高为log4n。树高明显矮了许多。

        但是如果我们采用多路归并,那么每次比较选出最小关键字的时间开销也会增多,虽然内存中的运行速度很快,但是有没有一种办法来解决这种选出最小关键字时间长的方法呢?

2.败者树

        败者树就可以解决这种由于多路归并来选择最小关键字慢的情况。首先让我们了解一下败者树的组成原理:首先给出8个元素,让他们两两比较,选出较小的关键字,然后用得到的4个关键字再次两两比较,选出较小的关键字,最后得到两个关键字,让他们比较,得出最小的关键字放在树的根结点,然后根结点的上方再连接一个结点用来存放每次的胜者(最小的关键字)。这种失败者停留在本层,胜利者去往下一层进行比较最终形成的树就是败者树。

        知道了败者树的定义之后,我们可以将排好序的归并段看作是一个成员,让这些归并段选出第一个关键字来进行比较用来构成败者树,然后用此次的胜利者(含有最小元素的归并段)再次选出一个关键字加入败者树。比如说构成败者树中的胜利者为归并段3,那么就要将叶结点中归并段3的关键字放入输出缓冲区,因为此时它已经有序了。剔除之后,归并段3所对应的叶结点就缺失了一个关键字,那么就要用归并段3的下一个关键字进行补充。

        这样一来,原本需要比较n次的n路归并,现在只需要比较树高(log2n),这么多次即可选出最小关键字。大大提高了归并的效率。

3.置换选择排序

        前面我们知道了败者树可以使n路归并的归并过程效率提高,但是再怎么提高,归并段的数量还是n,归并的过程还是要有n这个数量级。那么置换选择排序,就可以使归并段的数量大大降低,这样一来再利用败者树的特点就可以进一步降低外部排序的时间开销。

        实现方法:假设我们有50个关键字需要被分成若干归并段。如果用前面我们讲的方法,每个磁盘块的大小为5,那么排好序后就会有10个待排序的归并段。那么置换选择排序,排好序后,可能也就有3-4个归并段甚至更少。首先我们设定一个有3个字节的内存工作区(这个工作区的大小可以根据具体情况来具体分析),然后将前三个关键字放入内存工作区,选出最小的放入归并段1中,然后再从磁盘中选出一个关键字补充内存工作区,再选出一个最小的关键字放入归并段1,然后再从磁盘中选中一个关键字,放入内存工作区。假如我们这次选中的关键字小于我们前一个放在归并段1中的关键字,那么这个关键字此刻就会卡在内存工作区中无法出去,直到我们选到了3个这样的关键字,此时内存工作区的卡住了3个关键字无法放入归并段1中,那么此时我们就要将这三个关键字选出一个最小的放入一个新的归并段2中。这样循环下去得到的归并段将远远小于10。

4.最佳归并树

        知道了置换选择排序的原理后,我们也应该清楚了,置换选择排序得到的归并段大小不唯一,可能归并段1中含有19个关键字,而归并段2中只能含有5个关键字。此时我们还应该知道一件事情:最先进行归并的归并段还要与剩下的归并段进行归并,意思就是,最先进行归并的归并段,在归并树的叶结点位置,而它接下来还要进行树高次数的归并。

        这样一来,如果我们最先进行归并的归并段如果包含的关键字非常多,那么这个归并段在后续的归并过程中由于关键字太多,而归并次数又很多,导致时间复杂度升高。

        而最佳归并树就可以解决这种问题。我们可以将归并段看成是一个结点,归并段所带有的关键字数量看成是结点的权值。那么我们是不是就可以运用哈夫曼树来对这些结点进行树的构建。这样一来就可以保证这棵树的WPL是最低的,也可以降低时间开销。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值