排序和查找-线性排序算法和查找特定值

来源:互联网 发布:行知中学老师 编辑:程序博客网 时间:2024/05/16 16:22

一、排序算法的时间下界

常用排序算法中的冒泡排序、选择排序、插入排序等排序算法都是基于比较的。对于基于比较的排序算法,其时间下界为Ω(nlgn).

用决策树模型可以证明该结论。基于比较的排序算法的每一次排序都可以看做是一次决策,决策的结果是比较的两个值是大于或者小于等于。考虑三个元素的排序,以下标表示每个元素,我们可以构造如下的决策树:


图中的每个非页节点表示要比较的两个元素的下标,每个节点的左子树表示比较的两个元素中的前一个小于等于后一个,右子树表示被比较的两个元素的前一个大于后一个。每个叶子节点表示一个可能的排序结果。很显然根据排列组合的原理,三个元素有3!种排列,因此该决策树有3!个叶子。这也可以从另一方面进行解释:排序算法是针对的任意输入组合,因此所有的3!种排列形式都可能是最终的排序结果,那么为了得到一个由任意的输入的三个元素的排序结构,一个正确的排序算法也就必须可以得到这3!种排列中的任意一种,当我们使用基于比较的排序算法时,其对应的决策树就必须有3!个叶子。很显然对于n个节点的排序,该结论也成立。

在得到决策树之后,基于比较的排序算法的时间下界就很明显了,它就是决策树中根节点到叶子节点的最长路径,也就是决策树的高度。假设对n个节点进行排序的决策树的高度为h,由于决策树是一棵二叉树,因而可知树的最大叶子数目为2^h,同时根据对决策树的分析,决策树的叶子数目为n!,因而可得n!<= 2^h。对该算是取对数即可得h>=lg(n!)。又因为lg(n!)=Ω(nlgn),因而h>= Ω(nlgn)。所以h的最小值为Ω(nlgn),因此基于比较的排序算法的时间下界即最好情况为Ω(nlgn)。

二、线性排序算法

除了常用的基于比较的排序算法之外,还有一些非基于比较的排序算法,这些排序算法的排序效率不受Ω(nlgn)的限制。这些排序算法有:计数排序,基数排序,桶排序。

1.计数排序

1).基本概念

计数排序假设n个输入中的每一个元素都是介于0到k之间的整数,k为一个确定的有限值。其基本思想是对每一个输入元素x,确定小于x的元素个数,有了这个信息,就可以把元素x直接放到它在排序结果的位置上。其算法步骤(输入序列在数组A中,辅助数组C,结果存放于数组B中):

  1. 辅助数组C的所有元素清0,辅助数组长度为k,这也是为什么k要为一个确定的有限值的原因。
  2. 遍历待排序元素序列,对于序列中任意一个元素x,执行:C[x]=C[x] + 1。这一步的操作保证了辅助数组中下标为x的元素的值等于待排序序列中元素x的数目
  3. 遍历辅助数组C,执行C[x]=C[x-1] + 1。这一步保证了辅助数组中的下标为x的元素的值等于待排序序列中小于等于x的元素的数目。
  4. 以j为循环变量,从输入数组的最后一个下标开始遍历输入数组,并对每一步执行
    • B[C[A[j]]] = A[j],这一步保证了该算法的稳定性,当同一个元素出现多次时,它们的相对位置不变。
    • C[A[j]] = C[A[j]] - 1

2)性能

  1. 时间复杂度:很显然该算法只需要几次简单的循环遍历数组的操作,其时间复杂度为 Θ(n + k)
  2. 空间复杂度:该算法需要两个辅助数组,一个长度为k一个长度为待排序序列的长度
  3.  稳定性:该算法是稳定的

2.基数排序

1)基本思想

基数排序的思想是对于一个由n个d位数组成的待排序序列,首先将它们按照最低有效位的数字进行排序,然后按照次有效位排序。。。最后按照最高有效位排序。这种有效位排序次序可以保证在按照一个有效位进行排序时,只要两者大小不同就绝对可以按照它们的相对大小进行排序,如果使用最高有效位、次高有效位。。。最低有效位的次序,则是不能简单按照本次比较结果来排序的,比如91和19,按最高有效位排成了19,91,再按照最低有效位排序时是不能排成91,19的。
基数排序中另外一个需要注意的是:按照任意一个有效位排序时,排序必须是稳定的,这是因为在按照该位排序前,可能已经按照其它位将序列排序了,该位上的两个值在其它位上是有序的而且已经排好了,如果不稳定就会破坏以前的排序结果。
基数排序的算法很简单,就是依次按照各个有效位进行排序,只要保证各个有效位上的排序是稳定的,则完成各个有效位上的排序后,序列就是有序的了。举例如下:
待排序序列                         最低位排序结果           次第位排序结果             最高位排序结果
008                                                           761                                008                                  008
187                                                           842                                235                                  187
235                                                           235                                842                                  235
657                                                           765                                657                                  657
761                                                           187                                761                                  761
842                                                           657                                765                                  765
765                                                           008                                187                                  842

2)性能

  1. 时间复杂度:对于n个d位数,每一个数位可以取k个不同的值,如果每个有效位上的稳定排序的时间复杂度为 Θ(n + k),则基数排序的时间复杂度为Θ(d(n + k))。进一步的,可以对其进行扩展,对于给定的n个b(这里b为二进制的位数)位数和任何正整数r<=b,基数排序可以在Θ((b/r)(n + 2^r))的时间内完成排序d/r就相当于d,2^r相当于k。
  2. 空间复杂度:其空间复杂度取决于所使用的稳定排序的算法的空间复杂度。
  3. 稳定性:根据该算法的要求可知其是稳定的。

3.桶排序

1).基本思想

假设有一组长度为N的待排关键字序列K[1....n]。首先将这个序列划分成M个的子区间(桶) 。然后将待排序序列到各个桶中。然后对每个桶中的元素进行排序,最后再一次列出各个桶中的元素就可以得到排序后的序列。
桶排序实际上是采用了分治法,它通过映射将待排序序列映射到了各个桶中,这个过程中实际上已经进行了一部分排序的工作,它将输入序列按照某种(排序)要求划分成了小的序列;然后对每个桶中的元素使用其它的排序算法进行排序,最后再将各个桶的结果结合起来得到最后的结果。

2).性能

  1. 时间复杂度:桶排序的时间复杂度为映射时间复杂度加上各个桶排序的事件复杂度,假设各个桶采取插入排序,则为Θ(n) + O(n0^2) + O(n1^2)+ ... + O(n(n-1)^2),其中n0n1,n(n-1)为各个桶中元素的数目。当各个桶尺寸的平方和与总的元素数呈线性关系时,根据该式子同排序都可以以线性时间运行
  2. 空间复杂度:桶数目加上各个桶进行排序所需要的空间。
  3. 稳定性:取决于各个桶所采用的排序算法的稳定性。

三、查找特定值

1.最小值和最大值

查找给定序列中的最大值和最小值是经常被碰到的问题,很明显对于任意一个包含n个元素的序列来说,只要经过n-1次比较久可以确定其最大值或者最小值。如果要同时找到最大值和最小值则所需时间为3 * (n/2)向下取整,方法是:

  1. 如果序列长度n为奇数,则首先让当前的最大值和最小值都等于第一个元素;如果序列长度n为偶数,则首先比较前两个元素,然后取二者中的较小的为当前的最小值另一个为当前的最大值
  2. 每次取两个元素,向让两个元素相互比较,然后取其中的较小值和当前最小值比较,另一个和当前的最大值比较,并根据结果更新当前的最大、最小值
  3. 重复第2步,直到序列检查完毕

2.查找任意给定序列中的特定的值

对于任意给定的输入序列,可以在Θ(n)的时间内找到第i小的元素。算法的思想是采用快速排序的方法,但是区别于快速排序的是快速排序在将序列分为三部分后(小于选择的元素的部分,选择的元素,大于选择的元素的部分),会对小于所选择的元素的部分以及大于所选择的元素的部分递归的进行处理,但是在这里只需要根据所选择的元素的位置处理其中的一部分即可。算法思路(假设查找数组array[m..n]中第i小的元素):

  1. 如果m=n,则返回array[m]
  2. 将数组array划分为三部分,并返回所选择的元素的下标s
  3. 让q=s-m+1
  4. 如果q=i,则发挥array[q]
  5. 如果i<q,则递归的用该算法在数组array[m, s-1]中查找第i小的元素
  6. 否则,则递归的用该算法在数组array[s+1, n]中查找第i-q小的元素

3. 有序序列中的查找

如果要在给定的有序序列中查找,则速度可以更快。对于一个有序的数组,可以充分利用序列有序这个特点来进行查找,基本思想是:
  1. 从序列中找出一个元素,假设为x,则它将序列未被查找过的部分分为三部分:在x之前的部分,假设为A;x;以及在x之后的部分,假设为B(A和B都可能为空,A和B都为空表示序列已经只剩一个元素需要检查了,此时查找必然要结束,要么成功要么失败)。
  2. 将要查找的元素和x比较,如果相等则查找结束,否则根据其与x的大小关系来在A或者B中查找(x以及未被选中的部分都可以认为是已经查找过了。
选择x常用的方式是折半法,即每次都取未被查找序列中的中间的那个元素。很显然用这办法进行查找,所需时间复杂度为“logn向下取整+1",其中n为序列的长度。
这种方法也不是很完美,因为实际上它假设序列中所有元素被查找的概率是相同的,如果序列中每个元素被查找的概率是不等的,则这种方法就不能得到很好的真实查找效率。很自然的我们期望概率越大的元素越靠前,这就能降低被查找的次数较多的元素的真实查找时间,从而降低序列的真实查找时间,提高真实查找效率。这个时候就要用到次优二叉树。
假设一个包括n个元素的序列A有序(序列下标从1到n),且每个元素被查找的概率分别为w1,w2,w3,...wn,则次优查找树按照如下方式构造:
  1. 令pi =(wi+1 +wi+2 + … + wn) – (w1 + w2 + ... +wi-1)
  2. 选出使得pi最小的元素i当做要构造的二叉树的根
  3. 分别对在第i个元素之前的子序列和在第i个元素之后的子序列同样构造次优二叉树
算法实现中往往会利用如下公式来简化计算过程:
  • Wi=w1 + w2 +... + wi-1 + wi
  • 则pi = (wi+1+ wi+2 + … + wn)– (w1 + w2 + ... +wi-1) = Wn - Wi - Wi-1
因此只要在开始的时候将W1W2...Wn计算出来即可利用上边的pi计算公式简化计算。

四、外部排序

当待排序的序列很大,大的无法同时将它们存入内存中进行排序时,就需要进行特别的处理了。这样的待排序序列一般存储在文件中,即存储在外部存储器中,因而这种排序方法被称为外部排序。

1.外部排序方法

由于无法将数据全部加在到内存,因此很自然的就想到了分治法,基本思想是:

  1. 将待排序序列划分为m个小的序列
  2. 对每个小序列进行排序,并将结果存储到文件中
  3. 对得到的m个已经排好序的文件的内容进行合并,并将结果存储到结果文件中
很显然这里的步骤2和3都需要读写外部存储器。因而外部排序所需总时间可以表达为:

初始内部排序所需时间+每趟归并所需时间 * 归并趟数+归并趟数*一次归并IO操作所需时间

很显然:

  • 初始内部排序所需时间:取决于所采用的排序算法,算法确定,它就是确定的
  • 每趟归并所需时间* 归并趟数:每一趟归并操作就是一个内存中的比较合并操作,因此该项所需时间也是确定的,因此该项的时间为归并趟数乘以每趟归并所需时间
  • 归并趟数*一次IO操作所需时间:首先,为什么这一项会是这种表达方式,这是因为每一趟合并都需要将所有的数据多读入内存,然后处理完后再写入文件,而IO操作都是以块为单位的,因而在总数据量不变的情况下,每一趟所需的时间大致是相等的(之所以是大致相等是因为在归并中,有时候可能会出现落单的有序段,它就不必读到内存再写入文件了,因此是大致相等。比如如果有m=9,采用2路归并,则第一趟中,第9个小段就不需要读入内存再写入文件)
因此很明显,对于外部排序算法,关键在于如何减少归并的趟数。而对于被分为m个小的序列的大序列,采用k路归并时,其所需趟数为:logkm。

k路归并指的是将k个序列合并成一个新的序列(在k路归并中,如果一组归并可以不够k路,比如对5个序列进行二路归并,则就是将1和2进行归并,3和4进行归并,5自己进行”归并“)。

2.多路归并

根据前边的分析,外部排序关键在于如何减少归并次数。从直观上说增大k即可。但是先分析下外部排序中另一个可变项“每趟归并所需时间* 归并趟数”,该项中除了归并次数,还有一个变量就是归并时需要的比较次数,假设进行k路归并,显然为了将k路归并为一路,则需要:
  1. 从每一路中取出其最小(大)值
  2. 将这个k个值进行比较,找出其中的最小(大)值
  3. 将得到的元素放入结果序列
上述三步的操作要一直循环直到k路中所有元素都进入到了结果序列中。对于整个序列中的所有元素都需要这三步(更准确的说,最后一些元素可能不需要经过k-1次比较即可得到其相对大小,但是这不影响最终的结果)。因此如果要将总元素个数为n的m个序列用k路合并得到最终的序列,必须经过(k-1)*(n-1)次比较。因此“每趟归并所需时间* 归并趟数”这个可变项包含了可变因子 (k-1)*(n-1) * logkm。显然该项的值会随k变大而变大。
但是利用“败者树”可以使得从k个元素中选择最小(大)值仅需要log2k次比较,而不是k-1次比较,这就使得“每趟归并所需时间* 归并趟数”这个可变项的可变因子变成了log2k * logkm = log2m,与k无关,这样就可以增大k来降低总体的排序时间,但是k也不是越大越好,这是一个需要综合考量的取值。
我们以从7个元素中选择最大值来考察败者树的基本原理,假设7个元素为76,18,89,38,54,101,9,我们来逐步构造败者树
第一步我们得到:

每两个元素相比较,”失败者“即较小的被记录到它们的父节点。经过第二步我们可以得到:

在这一步中上一步的胜利者继续进行比较,'失败者'被记录到它们的父节点。然后经过第三部我们可以得到:

在这一步中上一步的胜利者继续进行比较,“失败者”98被记录到其父节点。胜利者101即是要选择的元素,显然只经过了3次比较(log27向上取整)就得到了结果。

这就是败者树的原理,当利用它进行k路归并时,如果某一路的当前元素在树中胜出,就从该路取下一个元素继续参与树的比较即可。这就是用败者树进行k路的归并的方法。

3.置换-选择排序

从上边的描述也可以看出,降低外部排序的另一个方法是减小m,那就要增大每个子序列的长度,但是一个子序列长度选多大才是最合适的是很难确定的。为此引入了置换-选择排序,它的特点是在得到初始归并段的过程中,选择最小(大)值的过程和IO输入、输出同时进行。

假设待排序的序列存放在文件input中,初始归并序列都输出到文件output中,内存中一次可以容纳m个元素,其算法过程如下:

  1. 从input文件中读取m个元素到内存中。
  2. 利用败者树从m个元素中选取一个当前最小的元素,并且将其记录到MIN中。
  3. 将值等于MIN的元素输出到文件output中。
  4. 如果input不空,则从input中读取下一个元素到内存中。
  5. 从内存中的所有关键字比MIN大的元素中选出具有最小关键字的元素,并且将MIN更新为该值。这一步将保证输出到文件中的每个序列中的元素都是递增的。
  6. 重复步骤3-5,直到选不出新的MIN元素为止,此时就得到一个初始归并序列,输出一个归并段结束标记到文件output中
  7. 重复步骤2-6,直到内存中没有任何元素

由于对于任意一个元素只要参与一趟归并就需要进行一次IO的读写操作,因此当各个序列的长度不同时,我们期望长度越长的序列参与归并的趟数越少,这样我们就可以降低总体的磁盘IO次数,显然赫夫曼树可以用于这个目的(用序列长度当做叶子节点权重来构造一颗具有最小带权路径长度的树)。