收藏 分享(赏)

从零开始学算法:十种排序算法介绍 by Matrix67.doc

上传人:nacm35 文档编号:7892441 上传时间:2019-05-29 格式:DOC 页数:13 大小:94KB
下载 相关 举报
从零开始学算法:十种排序算法介绍 by Matrix67.doc_第1页
第1页 / 共13页
从零开始学算法:十种排序算法介绍 by Matrix67.doc_第2页
第2页 / 共13页
从零开始学算法:十种排序算法介绍 by Matrix67.doc_第3页
第3页 / 共13页
从零开始学算法:十种排序算法介绍 by Matrix67.doc_第4页
第4页 / 共13页
从零开始学算法:十种排序算法介绍 by Matrix67.doc_第5页
第5页 / 共13页
点击查看更多>>
资源描述

1、从零开始学算法:十种排序算法介绍(上) Program Impossible | 2007-03-31 23:23| 17 Comments | 本文内容遵从 CC 版权协议 转载请注明出自今天我正式开始按照我的目录写我的 OI 心得了。我要把我所有学到的 OI 知识传给以后千千万万的 OIer。以前写过的一些东西不重复写了,但我最后将会重新整理,使之成为一个完整的教程。按照我的目录,讲任何东西之前我都会先介绍时间复杂度的相关知识,以后动不动就会扯到这个东西。这个已经写过了,你可以在这里看到那篇又臭又长的文章。在讲排序算法的过程中,我们将始终围绕时间复杂度的内容进行说明。我把这篇文章称之为“从

2、零开始学算法”,因为排序算法是最基础的算法,介绍算法时从各种排序算法入手是最好不过的了。给 出 n 个数,怎样将它们从小到大排序?下面一口气讲三种常用的算法,它们是最简单的、最显然的、最容易想到的。选择排序(Selection Sort)是说,每次从数列中找出一个最小的数放到最前面来,再从剩下的 n-1个数中选择一个最小的,不断做下去。插入排序(Insertion Sort)是,每次从数列中取一个还没有取出过的数,并按照大小关系插入到已经取出的数中使得已经取出的数仍然有序。冒泡排序(Bubble Sort)分为若干趟进行,每一趟排序从前往后比较每两个相邻的元素的大小(因此一趟排序要比较 n-1

3、对位置相邻的数)并在每次发现前面的那个数比紧接它 后的数大时交换位置;进行足够多趟直到某一趟跑完后发现这一趟没有进行任何交换操作(最坏情况下要跑 n-1趟,这种情况在最小的数位于给定数列的最后面时 发生) 。事实上,在第一趟冒泡结束后,最后面那个数肯定是最大的了,于是第二次只需要对前面 n-1个数排序,这又将把这 n-1个数中最小的数放到整个数列 的倒数第二个位置。这样下去,冒泡排序第 i 趟结束后后面 i 个数都已经到位了,第 i+1趟实际上只考虑前n-i 个数(需要的比较次数比前面所说的 n-1要 小) 。这相当于用数学归纳法证明了冒泡排序的正确性:实质与选择排序相同。上面的三个算法描述可

4、能有点模糊了,没明白的话网上找资料,代码和动画演示遍地 都是。这三种算法非常容易理解,因为我们生活当中经常在用。比如,班上的 MM 搞选美活动,有人叫我给所有MM 排个名。我们通常会用选择排序,即先找出自 己认为最漂亮的,然后找第二漂亮的,然后找第三漂亮的,不断找剩下的人中最满意的。打扑克牌时我们希望抓完牌后手上的牌是有序的,三个 8 挨在一起,后面紧 接着两个 9。这时,我们会使用插入排序,每次拿到一张牌后把它插入到手上的牌中适当的位置。什么时候我们会用冒泡排序呢?比如,体育课上从矮到高排队时, 站队完毕后总会有人出来,比较挨着的两个人的身高,指挥到:你们俩调换一下,你们俩换一下。这是很有启

5、发性的。这告诉我们,什么时候用什么排序最好。当人们渴望先知道排在前面的是谁时,我们用选择排序;当我们不断拿到新的数并想保持已有的数始终有序时,我们用插入排序;当给出的数列已经比较有序,只需要小幅度的调整一下时,我们用冒泡排序。我们来算一下最坏情况下三种算法各需要多少次比较和赋值操作。选择排序在第 i 次选择时赋值和比较都需要 n-i 次(在 n-i+1 个数中选一个出来作为当前最小值,其余 n-i个数与当前最小值比较并不断更新当前最小值) ,然后需要一次赋值操作。总共需要 n(n-1)/2 次比较与 n(n-1)/2+n 次赋值。插 入排序在第 i 次寻找插入位置时需要最多 i-1 次比较(从

6、后往前找到第一个比待插入的数小的数,最坏情况发生在这个数是所有已经取出的数中最小的一个的时 候) ,在已有数列中给新的数腾出位置需要 i-1 次赋值操作来实现,还需要两次赋值借助临时变量把新取出的数搬进搬出。也就是说,最坏情况下比较需要 n(n-1)/2 次,赋值需要 n(n-1)/2+2n 次。我这么写有点误导人,大家不要以为程序的实现用了两个数组哦,其实一个数组就够了,看看上面的演 示就知道了。我只说算法,一般不写如何实现。学算法的都是强人,知道算法了都能写出一个漂亮的代码来。冒泡排序第 i 趟排序需要比较 n-i 次,n-1 趟排序总共 n(n-1)/2 次。给出的序列逆序排列是最坏的情

7、况,这时每一次比较都要进行交换操作。一次交换操作需要 3 次赋值实现,因此冒泡排序最坏情况下需要赋值 3n(n-1)/2 次。按 照渐进复杂度理论,忽略所有的常数,三种排序的最坏情况下复杂度都是一样的:O(n2)。但实际应用中三种排序的效率并不相同。实践证明(政治考试时每 道大题都要用这四个字) ,插入排序是最快的(虽然最坏情况下与选择排序相当甚至更糟) ,因为每一次插入时寻找插入的位置多数情况只需要与已有数的一部分进 行比较(你可能知道这还能二分) 。你或许会说冒泡排序也可以在半路上完成,还没有跑到第 n-1 趟就已经有序。但冒泡排序的交换操作更费时,而插入排序中找 到了插入的位置后移动操作

8、只需要用赋值就能完成(你可能知道这还能用 move) 。本文后面将介绍的一种算法就利用插入排序的这些优势。我 们证明了,三种排序方法在最坏情况下时间复杂度都是 O(n2)。但大家想过吗,这只是最坏情况下的。在很多时候,复杂度没有这么大,因为插入和冒泡在数 列已经比较有序的情况下需要的操作远远低于 n2 次(最好情况下甚至是线性的) 。抛开选择排序不说(因为它的复杂度是“死”的,对于选择排序没有什么“好 ”的情况) ,我们下面探讨插入排序和冒泡排序在特定数据和平均情况下的复杂度。你会发现,如果把插入排序中的移动赋值操作看作是把当前取 出的元素与前面取出的且比它大的数逐一交换,那插入排序和冒泡排序

9、对数据的变动其实都是相邻元素的交换操作。下面我们说明,若只能对数列中相邻的数进行交 换操作,如何计算使得 n 个数变得有序最少需要的交换次数。我们定义逆序对的概念。假设我们要把数列从小到大排序,一个逆序对是指的在原数 列中,左边的某个数比右边的大。也就是说,如果找到了某个 i 和 j 使得 iAj,我们就说我们找到了一个逆序对。比如说,数列 3,1,4,2 中有三个逆序对,而一个已经有序的数列逆序对个数为 0。我们发现,交换两个相邻的数最多消除一个逆序对,且冒泡排序(或插入排序)中的一次 交换恰好能消除一个逆序对。那么显然,原数列中有多少个逆序对冒泡排序(或插入排序)就需要多少次交换操作,这个

10、操作次数不可能再少。若 给出的 n 个数中有 m 个逆序对,插入排序的时间复杂度可以说是 O(m+n)的,而冒泡排序不能这么说,因为冒泡排序有很多“无用” 的比较(比较后没有交 换) ,这些无用的比较超过了 O(m+n)个。从这个意义上说,插入排序仍然更为优秀,因为冒泡排序的复杂度要受到它跑的趟数的制约。一个典型的例子是这样 的数列:8, 2, 3, 4, 5, 6, 7, 1。在这样的输入数据下插入排序的优势非常明显,冒泡排序只能哭着喊上天不公。然而,我们并不想计算排序算法对于某个特定数据的效率。我们真正关心的是,对于所有可能出现的数据,算法的平均复杂度是多少。不用激动了,平均复杂度并不会低

11、于平方。下面证明,两种算法的平均复杂度仍然是 O(n2)的。我们仅仅证明算法需要的交换次数平均为 O(n2)就足够了。前面已经说过,它们需要的交换次数与逆序对的个数相同。我们将证明,n 个数的数列中逆序对个数平均 O(n2)个。计 算的方法是十分巧妙的。如果把给出的数列反过来(从后往前倒过来写) ,你会发现原来的逆序对现在变成顺序的了,而原来所有的非逆序对现在都成逆序了。正反 两个数列的逆序对个数加起来正好就是数列所有数对的个数,它等于 n(n-1)/2。于是,平均每个数列有 n(n-1)/4 个逆序对。忽略常数,逆序对平均 个数 O(n2)。上面的讨论启示我们,要想搞出一个复杂度低于平方级别

12、的排序算法,我们需要想办法能把离得老远的两个数进行操作。人 们想啊想啊想啊,怎么都想不出怎样才能搞出复杂度低于平方的算法。后来,英雄出现了,Donald Shell 发明了一种新的算法,我们将证明它的复杂度最坏情况下也没有 O(n2) (似乎有人不喜欢研究正确性和复杂度的证明,我会用实例告诉大家,这些证明是非常有意思的) 。他把这种算法叫做 Shell 增量排序算法(大家常说的希尔排 序) 。Shell 排序算法依赖一种称之为“排序增量”的数列,不同的增量将导致不同的效率。假如我们对 20 个数进行排序,使用的增量为 1,3,7。那么,我们首先对这 20 个数进行“7-排序”(7-sorted

13、ness)。所谓 7-排序,就是按照位置除以 7 的余数分组进行排序。具体地 说,我们将把在 1、8、15 三个位置上的数进行排序,将第 2、9、16个数进行排序,依此类推。这样,对于任意一个数字 k,单看 A(k), A(k+7), A(k+14), .这些数是有序的。7-排序后,我们接着又进行一趟 3-排序(别忘了我们使用的排序增量为 1,3,7) 。最后进行 1-排序(即普通的排序)后整个 Shell 算法完成。看看我们的例子:3 7 9 0 5 1 6 8 4 2 0 6 1 5 7 3 4 9 8 2 B 队列:1 2 7 8 9 = B 队列:1 2 7 8 9 = B 队列:1

14、2 7 8 9 输出:1 1 2 输出:1 1 2 3 输出:1 1 2 3 5 输出:1 1 2 3 5 7我希望你明白了这是怎么做的。这个做法显然是正确的,复杂度显然是线性。归 并排序 (Merge Sort)将会用到上面所说的合并操作。给出一个数列,归并排序利用合并操作在 O(nlogn)的时间内将数列从小到大排序。归并排序用的是分治 (Divide and Conquer)的思想。首先我们把给出的数列平分为左右两段,然后对两段数列分别进行排序,最后用刚才的合并算法把这两段(已经排过序的)数列合并为一 个数列。有人会问“对左右两段数列分别排序时用的什么排序”么?答案是:用归并排序。也就是

15、说,我们递归地把每一段数列又分成两段进行上述操作。你不需要 关心实际上是怎么操作的,我们的程序代码将递归调用该过程直到数列不能再分(只有一个数)为止。初看这个算法时有人会误以为时间复杂度相当高。我们下面给出的一个图将用非递归的眼光来看归并排序的实际操作过程,供大家参考。我们可以借助这个图证明,归并排序算法的时间复杂度为 O(nlogn)。3 1 4 1 5 9 2 7 / / / /1 3 1 4 5 9 2 7 / /1 1 3 4 2 5 7 9 /1 1 2 3 4 5 7 9上 图中的每一个 “ / ”表示的是上文所述的线性时间合并操作。上图用了 4 行来图解归并排序。如果有 n个数,

16、表示成上图显然需要 O(logn)行。每一行的合并操作复杂度总和都 是 O(n),那么 logn 行的总复杂度为O(nlogn)。这相当于用递归树的方法对归并排序的复杂度进行了分析。假设,归并排序的复杂度为 T(n),T(n)由两个 T(n/2)和一个关于 n 的线性时间组成,那么 T(n)=2*T(n/2)+O(n)。不断展开这个式子我们可以同样可以得到 T(n)=O(nlogn)的结论,你可以自己试试。如果你能在线性的时间里把分别计算出的两组不同数据的结果合并在一起,根据 T(n)=2*T(n/2)+O(n)=O(nlogn),那么我们就可以构造 O(nlogn)的分治算法。这个结论后面经

17、常用。我们将在计算几何部分举 一大堆类似的例子。如果你第一次见到这么诡异的算法,你可能会对这个感 兴趣。分治是递归的一种应用。这是我们第一次接触递归运算。下面说的快速排序也是用的递归的思想。递归程序的复杂度分析通常和上面一样,主定理 (Master Theory)可以简化这个分析过程。主定理和本文内容离得太远,我们以后也不会用它,因此我们不介绍它,大家可以自己去查。有个名词在这里的话找学习资 料将变得非常容易,我最怕的就是一个东西不知道叫什么名字,半天找不到资料。归并排序有一个有趣的副产品。利用归并排序能够在 O(nlogn)的时间里计算出给定序列里逆序对的个数。你可以用任何一种平衡二叉树来完

18、成这个操作,但用归并排序统计逆序对更方便。我们讨论逆序对一般 是说的一个排列中的逆序对,因此这里我们假设所有数不相同。假如我们想要数 1, 6, 3, 2, 5, 4 中有多少个逆序对,我们首先把这个数列分为左右两段。那么一个逆序对只可能有三种情况:两个数都在左边,两个数都在右边,一个在左一个在右。在左右两段 分别处理完后,线性合并的过程中我们可以顺便算出所有第三种情况的逆序对有多少个。换句话说,我们能在线性的时间里统计出 A 队列的某个数比 B 队列的某个数 大有多少种情况。A 队列:1 3 6 A 队列: 1 3 6 A 队列: 1 3 6 A 队列:1 3 6 A 队列:1 3 6B 队

19、列:2 4 5 = B 队列:2 4 5 = B 队列:2 4 5 = B 队列:2 4 5 = B 队列:2 4 5 输出: 输出:1 输出:1 2 输出:1 2 3 输出:1 2 3 4每 一次从 B 队列取出一个数时,我们就知道了在 A 队列中有多少个数比 B 队列的这个数大,它等于 A 队列现在还剩的数的个数。比如,当我们从 B 队列中取出 2 时,我 们同时知道了 A 队列的 3 和 6 两个数比 2 大。在合并操作中我们不断更新 A 队列中还剩几个数,在每次从 B 队列中取出一个数时把当前 A 队列剩的数目加进最终答案 里。这样我们算出了所有“大的数在前一半,小的数在后一半” 的情

20、况,其余情况下的逆序对在这之前已经被递归地算过了。=华丽的分割线=堆排序(Heap Sort)利用了堆 (Heap)这种数据结构(什么是堆?) 。 堆的插入操作是平均常数的,而删除一个根节点需要花费 O(log n)的时间。因此,完成堆排序需要线性时间建立堆(把所有元素依次插入一个堆) ,然后用总共 O(nlogn)的时间不断取出最小的那个数。只要堆会搞,堆 排序就会搞。堆在那篇日志里有详细的说明,因此这里不重复说了。=华丽的分割线=快 速排序(Quick Sort)也应用了递归的思想。我们想要把给定序列分成两段,并对这两段分别进行排序。一种不错的想法是,选取一个数作为“关键字”,并把其它数分

21、割为两 部分,把所有小于关键字的数都放在关键字的左边,大于关键字的都放在右边,然后递归地对左边和右边进行排序。把该区间内的所有数依次与关键字比较,我们就 可以在线性的时间里完成分割的操作。完成分割操作有很多有技巧性的实现方法,比如最常用的一种是定义两个指针,一个从前往后找找到比关键字大的,一个从后 往前找到比关键字小的,然后两个指针对应的元素交换位置并继续移动指针重复刚才的过程。这只是大致的方法,具体的实现还有很多细节问题。快速排序是我们最 常用的代码之一,网上的快速排序代码五花八门,各种语言,各种风格的都有。大家可以随便找一个来看看,我说过了我们讲算法但不讲如何实现。NOIp 很简 单,很多

22、人 NOIp 前就背了一个快速排序代码就上战场了。当时我把快速排序背完了,抓紧时间还顺便背了一下历史,免得晚上听写又不及格。不 像归并排序,快速排序的时间复杂度很难计算。我们可以看到,归并排序的复杂度最坏情况下也是O(nlogn)的,而快速排序的最坏情况是 O(n2)的。 如果每一次选的关键字都是当前区间里最大(或最小)的数,那么这样将使得每一次的规模只减小一个数,这和插入排序、选择排序等平方级排序没有区别。这种情 况不是不可能发生。如果你每次选择关键字都是选择的该区间的第一个数,而给你的数据恰好又是已经有序的,那你的快速排序就完蛋了。显然,最好情况是每一次 选的数正好就是中位数,这将把该区间

23、平分为两段,复杂度和前面讨论的归并排序一模一样。根据这一点,快速排序有一些常用的优化。比如,我们经常从数列中随 机取一个数当作是关键字(而不是每次总是取固定位置上的数) ,从而尽可能避免某些特殊的数据所导致的低效。更好的做法是随机取三个数并选择这三个数的中位 数作为关键字。而对三个数的随机取值反而将花费更多的时间,因此我们的这三个数可以分别取数列的头一个数、末一个数和正中间那个数。另外,当递归到了一定 深度发现当前区间里的数只有几个或十几个时,继续递归下去反而费时,不如返回插入排序后的结果。这种方法同时避免了当数字太少时递归操作出错的可能。下面我们证明,快速排序算法的平均复杂度为 O(nlog

24、n)。不同的书上有不同的解释方法,这里我选用算法导论上的讲法。它更有技巧性一些,更有趣一些,需要转几个弯才能想明白。看 一看快速排序的代码。正如我们提到过的那种分割方法,程序在经过若干次与关键字的比较后才进行一次交换,因此比较的次数比交换次数更多。我们通过证明一次 快速排序中元素之间的比较次数平均为 O(nlogn)来说明快速排序算法的平均复杂度。证明的关键在于,我们需要算出某两个元素在整个算法过程中进行过比 较的概率。我们举一个例子。假如给出了 1 到 10 这 10 个数,第一次选择关键字 7 将它们分成了1,2,3,4,5,6 和 8,9,10两部分,递归左边时我们选择了 3 作为关键字

25、,使得左部分又被分割为 1,2和4,5,6。我们看到,数字 7 与其它所有数都比较过一 次,这样才能实现分割操作。同样地,1 到 6 这 6 个数都需要与 3 进行一次比较(除了它本身之外) 。然而,3 和 9 决不可能相互比较过,2 和 6 也不可能进行过比 较,因为第一次出现在 3和 9,2 和 6 之间的关键字把它们分割开了。也就是说,两个数 A(i)和 A(j)比较过,当且仅当第一个满足 A(i)m 时,我们递归地寻找右边的元素中第 k-m 小的数。由于我们 不考虑所有的数的顺序,只需要递归其中的一边,因此复杂度大大降低。复杂度平均线性,我们不再具体证了。还有一种算法可以在最坏 O(n

26、)的时间里找出第 k 小元素。那是我见过的所有算法中最没有实用价值的算法。那个 O(n)只有理论价值。=华丽的分割线=我 们前面证明过,仅仅依靠交换相邻元素的操作,复杂度只能达到 O(n2)。于是,人们尝试交换距离更远的元素。当人们发现 O(nlogn)的排序算法似乎 已经是极限的时候,又是什么制约了复杂度的下界呢?我们将要讨论的是更底层的东西。我们仍然假设所有的数都不相等。我们总是不断在数与数 之间进行比较。你可以试试,只用4次比较绝对不可能给4个数排出顺序。每多进行一次比较我们就又多知道了一个大小关系,从4次比较中一共可以获知4个大小 关系。4个大小关系共有24=16种组合方式,而4个数的

27、顺序一共有4!=24种。也就是说,4次比较可能出现的结果数目不足以区分24种可能的顺序。 更一般地,给你 n 个数叫你排序,可能的答案共有 n!个,k 次比较只能区分2k 种可能,于是只有2k=n!时才有可能排出顺序。等号两边取对 数,于是,给 n 个数排序至少需要 log2(n!)次。注意,我们并没有说明一定能通过 log2(n!)次比较排出顺序。虽然25=32超过了4!,但这 不足以说明5次比较一定足够。如何用5次比较确定4个数的大小关系还需要进一步研究。第一次例外发生在 n=12的时候,虽然22912!,但现 已证明给12个数排序最少需要30次比较。我们可以证明 log(n!)的增长速度

28、与 nlogn 相同,即 log(n!)=(nlogn)。这是排序所需要 的最少的比较次数,它给出了排序复杂度的一个下界。log(n!)=(nlogn)的证明也附在本文最后。这篇日志的 第三题中证明 log2(N)是最优时用到了几乎相同的方法。那种“ 用天平称出重量不同的那个球至少要称几次”一类题目也可以用这种方法来解决。事实上,这 里有一整套的理论,它叫做信息论。信息论是由香农(Shannon)提出的。他用对数来表示信息量,用熵来表示可能的情况的随机性,通过运算可以知道你目 前得到的信息能够怎样影响最终结果的确定。如果我们的信息量是以2为底的,那信息论就变成信息学了。从根本上说,计算机的一切

29、信息就是以2为底的信息量 (bits=binary digits),因此我们常说香农是数字通信之父。信息论和热力学关系密切,比如熵的概念是直接 从热力学的熵定义引申过来的。和这个有关的东西已经严重偏题了,这里不说了,有兴趣可以去看信息论与编码理论 。我对这个也很有兴趣,半懂不懂的,很想 了解更多的东西,有兴趣的同志不妨加入讨论。物理学真的很神奇,利用物理学可以解决很多纯数学问题,我有时间的话可以举一些例子。我他妈的为啥要选文科 呢。后面将介绍的三种排序是线性时间复杂度,因为,它们排序时根本不是通过互相比较来确定大小关系的。附1:(1/n)=(log n) 的证明首 先我们证明,(1/n)=O(

30、log n)。在式子1+1/2+1/3+1/4+1/5+.中,我们把1/3变成1/2,使得两个1/2 加起来凑成一个1;再把1/5,1/6和1/7全部 变成1/4 ,这样四个1/4加起来又是一个1。我们把所有1/2k 的后面2k-1 项全部扩大为1/2k,使得这2k 个分式加起来是一个1。现 在,1+1/2+.+1/n 里面产生了几个1呢?我们只需要看小于 n 的数有多少个2的幂即可。显然,经过数的扩大后原式各项总和为 log n。O(logn)是 (1/n)的复杂度上界。然后我们证明,(1/n)=(log n)。在式子1+1/2+1/3+1/4+1/5+.中,我们把1/3变成1/4,使得两个

31、1/4加起来凑成一个1/2;再把1/5,1/6和1/7 全部变成1/8,这样四个1/8加起来又是一个1/2 。我们把所有1/2k 的前面2k-1 项全部缩小为1/2k,使得这2k 个分式加起来是一个1 /2。现在,1+1/2+.+1/n 里面产生了几个1/2呢?我们只需要看小于 n 的数有多少个2的幂即可。显然,经过数的缩小后原式各项总和为1 /2*logn。(logn) 是 (1/n)的复杂度下界。附2:log(n!)=(nlogn)的证明首先我们证明,log(n!)=O(nlogn) 。显然 n!(n/2)(n/2)。两边取对数,log(n!)(n/2)log(n/2),后者即 (nlog

32、n)。因 此,(nlogn)是log(n!)的复杂度下界。今天写到这里了,大家帮忙校对哦那么,有什么方法可以不用比较就能排出顺序呢?借助 Hash 表的思想,多数人都能想出这样一种排序算法来。我们假设给出的数字都在一定范围中,那么我们就可以开一个范围相同的数组,记录这个数字是否出现过。由于数字有可能有重复,因此 Hash 表的概念需要扩展,我们需要把数组类型改成整型,用来表示每个数出现的次数。看这样一个例子,假如我们要对数列3 1 4 1 5 9 2 6 5 3 5 9进行排序。由于给定数字每一个都小于10,因此我们开一个0到9的整型数组 Ti,记录每一个数出现了几次。读到一个数字 x,就把对

33、应的 Tx加一。A= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9+-+-+-+-+-+-+-+-+-+-+数字 i: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |+-+-+-+-+-+-+-+-+-+-+出现次数 Ti: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |+-+-+-+-+-+-+-+-+-+-+最后,我们用一个指针从前往后扫描一遍,按照次序输出0到9,每个数出现了几次就输出几个。假如给定的数是 n 个大小不超过 m 的自然数,显然这个算法的复杂度是 O(m+n)的。我 曾经以为,这

34、就是线性时间排序了。后来我发现我错了。再后来,我发现我曾犯的错误是一个普遍的错误。很多人都以为上面的这个算法就是传说中的计数排序。问 题出在哪里了?为什么它不是线性时间的排序算法?原因是,这个算法根本不是排序算法,它根本没有对原数据进行排序。问题一:为什么说上述算法没有对数据进行排序?STOP! You should think for a while.我们班有很多 MM。和身高相差太远的 MM 在一起肯定很别扭,接个吻都要弯腰才行(小猫矮 死了) 。为此,我希望给我们班的 MM 的身高排序。我们班 MM 的身高,再离谱也没有超过2米的,这很适合用我们刚才的算法。我们在黑板上画一个100到 2

35、00的数组,MM 依次自曝身高,我负责画 “正” 字统计人数。统计出来了,从小到大依次为141, 143, 143, 147, 152, 153, .。这算哪门子排序?就一排数字对我有什么用,我要知道的是哪个 MM 有多高。我们仅仅把元素的属性值从小到大列了出来,但我们没有对元素本身进行排 序。也就是说,我们需要知道输出结果的每个数值对应原数据的哪一个元素。下文提到的“排序算法的稳定性”也和属性值与实际元素的区别有关。问题二:怎样将线性时间排序后的输出结果还原为原数据中的元素?STOP! You should think for a while.同 样借助 Hash 表的思想,我们立即想到了类

36、似于开散列的方法。我们用链表把属性值相同的元素串起来,挂在对应的 Ti上。每次读到一个数,在增加 Ti的同时我们把这个元素放进 Ti延伸出去的链表里。这样,输出结果时我们可以方便地获得原数据中的所有属性值为 i 的元素。A= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9+-+-+-+-+-+-+-+-+-+-+数字 i: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |+-+-+-+-+-+-+-+-+-+-+出现次数 Ti: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |+-+o-+-o-+-o-+

37、-o-+-o-+-o+-+-+-o-+| | | | | | |+-+ +-+ | | +-+ +-+ | | A1 | | | A6A2 A7 | A3 A5 A8 | | | A12A4 A10 A9|A11形象地说,我们在地上摆10个桶,每个桶编一个号,然后把数据分门别类放在自己所属的桶里。这种排序算法叫做桶式排序(Bucket Sort)。本文最后你将看到桶式排序的另一个用途。链表写起来比较麻烦,一般我们不使用它。我们有更简单的方法。问题三:同样是输出元素本身,你能想出不用链表的其它算法么?STOP! You should think for a while.A= 3, 1, 4, 1

38、, 5, 9, 2, 6, 5, 3, 5, 9+-+-+-+-+-+-+-+-+-+-+数字 i: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |+-+-+-+-+-+-+-+-+-+-+出现次数 Ti: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |+-+-+-+-+-+-+-+-+-+-+修改后的 Ti: | 0 | 2 | 3 | 5 | 6 | 9 | 10| 10| 10| 12|+-+-+-+-+-+-+-+-+-+-+所有数都读入后,我们修改 Ti数组的值,使得 Ti表示数字 i 可能的排名的最大值。比如,

39、1最差排名第二,3最远可以排到第五。T 数组的最后一个数应该等于输入数据的数字个数。修改 T 数组的操作可以用一次线性的扫描累加完成。我 们还需要准备一个输出数组。然后,我们从后往前扫描 A 数组,依照 T 数组的指示依次把原数据的元素直接放到输出数组中,同时 Ti的值减一。之所以从后往 前扫描 A 数组,是因为这样输出结果才是稳定的。我们说一个排序算法是稳定的(Stable),当算法满足这样的性质:属性值相同的元素,排序后前后位置不 变,本来在前面的现在仍然在前面。不要觉得排序算法是否具有稳定性似乎关系不大,排序的稳定性在下文的某个问题中将变得非常重要。你可以倒回去看看前面说 的七种排序算法

40、哪些是稳定的。例子中,A 数组最后一个数 9所对应的 T9=12,我们直接把9放在待输出序列中的第12个位置,然后 T9变成11(这样下一次再出现9时就应该放在第11位) 。A= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9 -Ti= 0, 2, 3, 5, 6, 9, 10, 10, 10, 11Ans = _ _ _ _ _ _ _ _ _ _ _ 9接下来的几步如下:A= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5 -Ti= 0, 2, 3, 5, 6, 8, 10, 10, 10, 11Ans = _ _ _ _ _ _ _ _ 5 _ _

41、9A= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3 -Ti= 0, 2, 3, 4, 6, 8, 10, 10, 10, 11Ans = _ _ _ _ 3 _ _ _ 5 _ _ 9A= 3, 1, 4, 1, 5, 9, 2, 6, 5 -Ti= 0, 2, 3, 4, 6, 7, 10, 10, 10, 11Ans = _ _ _ _ 3 _ _ 5 5 _ _ 9这种算法叫做计数排序(Counting Sort)。正确性和复杂度都是显然的。问题四:给定数的数据范围大了该怎么办?STOP! You should think for a while.前面的算法只有在数据的范围

42、不大时才可行,如果给定的数在长整范围内的话,这个算法是不可行的,因为你开不下这么大的数组。Radix 排序(Radix Sort)解决了这个难题。昨天我没事翻了一下初中(9班)时的同学录,回忆了一下过去。我把比较感兴趣的 MM 的生日列在下面(绝对真实) 。如果列表中的哪个 MM 有幸看到了这篇日志(几乎不可能) ,左边的 Support 栏有我的电子联系方式,我想知道你们怎么样了。排名不分先后。 19880818 19880816 19890426 19880405 19890125 19881004 19881209 19890126 19890228这就是我的数据了。现在,我要给这些数排

43、序。假如我的电脑只能开出099的数组,那计数排序算法最多对两位数进行排序。我就把 每个八位数两位两位地分成四段(图1) ,分别进行四次计数排序。地球人都知道月份相同时应该看哪一日,因此我们看月份的大小时应该事先保证日已经有序。换 句话说,我们先对“最不重要”的部分进行排序。我们先对所有数的最后两位进行一次计数排序(图2) 。注意观察1月26号的 MM 和4月26号的MM,本次排 序中它们的属性值相同,由于计数排序是稳定的,因此4月份那个排完后依然在1月份那个的前头。接下来我们对百位和千位进行排序(图3) 。你可以看到两个 26日的 MM 在这一次排序中分出了大小,而月份相同的 MM 依然保持日

44、数有序(因为计数排序是稳定的) 。最后我们对年份排序(图4) ,完成整个算法。大家都 是跨世纪的好儿童,因此没有图5了。这种算法显然是正确的。它的复杂度一般写成 O(d*(n+m),其中 n 表示 n 个数,m 是我开的数组大小(本例中 m=100) , d 是一个常数因子(本例中 d=4) 。我们认为它也是线性的。问题五:这样的排序方法还有什么致命的缺陷?STOP! You should think for a while.即使数据有 30 位,我们也可以用 d=5 或 6 的 Radix 算法进行排序。但,要是给定的数据有无穷多位怎么办?有人说,这可能么。这是可能的,比如给定的数据是小数(

45、更准确地说,实数) 。基于比较的排序可以区分355/113 和 哪个大,但你不知道 Radix 排序需要精确到哪一位。这下惨了,实数的出现把貌似高科技的线性时间排序打回了农业时代。这时,桶排序再度出山,挽救了线性时间排序悲惨的命运。问题六:如何对实数进行线性时间排序?STOP! You should think for a while.我 们把问题简化一下,给出的所有数都是 0 到 1 之间的小数。如果不是,也可以把所有数同时除以一个大整数从而转化为这种形式。我们依然设立若干个桶,比如,以 小数点后面一位数为依据对所有数进行划分。我们仍然用链表把同一类的数串在一起,不同的是,每一个链表都是有序

46、的。也就是说,每一次读到一个新的数都要进 行一次插入排序。看我们的例子:A= 0.12345, 0.111, 0.618, 0.9, 0.99999+-+-+-+-+-+-+-+-+-+-+十分位: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |+-+-o-+-+-+-+-+-o-+-+-+-o-+| | |A2=0.111 A3=0.618 A4=0.9| |A1=0.12345 A5=0.99999假 如再下一个读入的数是 0.122222,这个数需要插入到十分位为 1 的那个链表里适当的位置。我们需要遍历该链表直到找到第一个比 0.122222 大的

47、数,在例子中则应该插入到链表中 A2和 A1之间。最后,我们按顺序遍历所有链表,依次输出每个链表中的每个数。这个算法显然是正确的,但复杂度显然不是线性。事实上,这种算法最坏情况下是 O(n2)的,因为当所有数的十分位都相同时算法就是一个插入排序。和原来一样,我们下面要计算算法的平均时间复杂度,我们希望这种算法的平均复杂度是线性的。这次算平均复杂度我们用最笨的办法。我们将算出所有可能出现的情况的总时间复杂度,除以总的情况数,得到平均的复杂度是多少。每 个数都可能属于 10 个桶中的一个,n 个数总的情况有 10n 种。这个值是我们庞大的算式的分母部分。如果一个桶里有 K 个元素,那么只与这个桶有关的操作有 O(K2)次,它就是一次插入排序的操作次数。下面计算,在 10n 种情况中,K0=1 有多少种情况。K0=1 表示,n 个数中只有一个数在 0 号桶,其余 n-1 个数的十分位就只能在 1 到 9 中选

展开阅读全文
相关资源
猜你喜欢
相关搜索

当前位置:首页 > 企业管理 > 管理学资料

本站链接:文库   一言   我酷   合作


客服QQ:2549714901微博号:道客多多官方知乎号:道客多多

经营许可证编号: 粤ICP备2021046453号世界地图

道客多多©版权所有2020-2025营业执照举报