SETI@home可以在杂乱的射电数据中搜寻独特的讯号,你能在大街上的嘈杂声中清晰分辨出一个尖细的女声大叫“亚美蝶”。这些现象都表明,有时对集合里的所有元素进行整体考察可以很快找出我们所要找的个体。去年我们搞合唱比赛时,我又想到了一个绝佳的例子:你可以在合唱声中清楚地听到是否有人跑调。考虑这样一个问题,假如合唱团里有一个人唱歌始终走调,但我听不出来是谁走调,只能听出当前正在唱歌的人中是否有唱走调了的人。那么,我如何才能迅速地揪出那个唱走调的人?利用经典二分法,我们可以在log2(n)次合唱后找出唱走调了的人。每一次,我都把剩下的人平均分成两组,然后选其中一组来合唱:如果听不到走调的声音,这一组的人就全部过关;如果听到有人走调,那另一组里的人都可以被排除了。递归地对剩下的组进行同样的操作,log2(n)次操作后必定可以找出那个唱歌走调的人。
现在的问题变得有些麻烦了。假如我们知道合唱队里有一个人唱歌爱跑调,但他不是总会跑调。具体地说,他只有1/2的概率唱错,但其余1/2的时间里他却唱得很准。现在,传统的二分法不再适用了,因为没有走调声已经不能起到排除的作用了。你能想出多少种可行的算法来找出那个人?下面提出一些可行的方法,你认为哪种方法更好?你能求出这些算法所需要的检测次数的期望值各是多少吗?
1. 不断地随机生成一个大小为n/2的子集并对其进行检测,直到某次不能通过检测为止,然后递归地对其进行操作。
2. 所选的子集大小为n/2是最优的吗?把上面这种方法的n/2改成n/a,常数a的最优值是多少?
3. 检测次数的期望值还可以更小吗?我们想到,每次都重新生成一个新的集合其实并不科学,新集合本身是否包含老鼠屎也是得碰碰运气的。因此,对方法1的一个合理改进是:把集合平均划分为两个部分,交替对它们进行检测直到某次检测没通过为止,然后对该组递归操作下去。这种方法真的比前两种好吗?它所需要的期望次数是多少?
4. 尝试对方法3进行改进。如果把集合平均划分成3份并循环进行检测,效果会不会更好一些?
1. 选取的子集有1/2的概率覆盖了我们要找的那个人,子集里有他而他这次恰好又唱走调了则有1/4的概率。因此,不管规模有多大,平均需要4次才能把规模缩小一半。因此,检测次数的期望值为4*log2(n)。为了方便比较期望值的大小,后面的答案我们一律表示成一个常数乘以log2(n)的形式。
2. 类似地,平均需要2a次检测才能把规模缩小到原来的1/a,因此总共花费的检测次数为2a*log2(n)/log2(a)。对函数求导,可得当a为e时函数值达到最小。此时的检测次数期望值为2e*log2(n)/log2(e)≈3.7683 * log2(n)。
3. 这个就经典了。设方法3里把规模缩小一半所需要的检测的期望次数为m,下面我们来看m应该等于多少。把n个人平均分成两组,我们要找的老鼠屎有1/2的概率在第一组,有1/2的概率在第二组。因此,第一次就测出问题来有1/4的可能,第二次就测出问题也有1/4的可能。对于剩下的1/2种情况,局面变得又和最开始一样,只是平均需要的检测次数比原来多了2。根据期望值的定义,有m=(1/4)*1 + (1/4)*2 + (1/2)*(m+2),解得m=3.5。总的检测次数就是3.5 * log2(n),它比前面两种方法都要好。你可能不同意上面求m的方法。这没啥,如果你不断对m进行迭代,你会发现展开出来的式子就是最标准的期望值定义。
4. 类似地,有m=(1/6)*1 + (1/6)*2 + (1/6)*3 + (1/2)*(m+3),解得m=5。于是,把规模缩小到原来的1/3平均需要5次检测,总的检测次数为5*log2(n)/log2(3)≈3.1546 * log2(n)。
题目来源:IBM Ponder This Dec07
原文还从熵的角度探寻了问题的最优算法,感兴趣的读者可以去看一看
似乎MM都很喜欢拼图游戏。如果MM过生日你不知道送她什么,送她一副拼图是一个不错的选择(事实上原来我也曾干过这事)。如果你失恋了,或者挂科了,或者这个月没饭钱了,或者怀疑自己的性取向,感到很郁闷的时候,静下心来玩一玩拼图游戏可以让你暂时忘掉烦恼。当你最终完成整个拼图时,你会有前所未有的成就感。当然,只有那些有耐心的人才觉得拼图有趣,像我这样的人肯定拼个十几二十分钟就觉得烦了。计算机搞久了的人往往都很没耐心,同一个操作反复执行的次数多了就觉得很烦,心里总会想这种机械操作交给傻B计算机去做该多省事啊。有时我会想,计算机是否有什么牛B算法可以用来解决拼图问题。今天我们要研究的是,如何把拼图游戏描述成一个信息学问题,计算机是否有更高效的算法来解决这个问题。
传统的拼图一共有w*h个正方形小块,最终将拼成一个w*h的矩形图案。我们大致有以下两种依据来确定一个小块的位置:根据这一小块上的图案来确定它在整幅图片中的位置,或者从形状上观察这一小块可以和其它哪些块拼接。于是,拼图游戏变成了这样一种交互式的问题:允许你询问某一块是否在指定的位置,或者某两块是否相连,你如何尽早地完成整个拼图。具体地说,你可以:
- 询问拼块A是否在(x,y)上,交互库返回yes/no
- 询问拼块A和拼块B是否相连,交互库返回yes/no
有时候,你并不能把拼图完全当作一个顶点最大度为4的无向图。多数情况下两个拼块只能按某一个方向上的某一种顺序相连。为了更贴近拼图游戏的真实情况,我们可以假定,对于第二个问题如果返回的是yes,则交互库还会告诉你A应该接在B的什么方向。现在的问题是,完成整个拼图最少需要多少次询问?
假如拼图共有n块,询问的次数不会超过O(n^2)。对于每一个拼块,我都像傻B一样挨着挨着询问“它是不是在这里”,O(n^2)次询问可以保证我完成整副拼图。我们希望知道,是否有算法可以使用O(nlogn)甚至更少的询问次数?
答案是否定的。对于拼图问题,计算机并没有英明到哪里去,它也只能像傻B一样一个一个去试。我们下面将证明,不管你怎么努力,询问次数再怎么也不会低于O(n^2)。首先我们需要说明的是,问题2实际上并不能带给我们多大的帮助。

如上图,我们把整个拼图划分成一个一个的“十字架”,并且挖掉每个十字架正中间的那个格子(深灰色的格子)。注意到关于这种划分的三个重要性质:
- 每个浅灰色的格子最多与一个深灰色的格子相邻
- 任何两个深灰色的格子都不相邻
- 深灰色的格子共有n/5个(可能有常数级别的偏差)
现在,假如整个拼图里只剩这些被挖掉的深灰色格子还没确定,其它的格子上都已经放好了正确的拼块。再换句话说,在拼图游戏过程中,拼块是否应放在浅灰色的格子里,若可以则应该放在哪个格子,以及浅灰色格子之间的邻接状态都是已经知道的了,只要是不涉及深灰色格子的信息,你要什么我就给你什么。此时,我们只剩下n/5个格子(仍然是O(n)个格子),并且询问1与询问2变得完全等价;你要问拼块A和拼块B是否相邻,还不如直接问拼块是否应放在某个洞里。于是,问题变为这样,只凭借询问1来确定O(n)个拼块的位置需要多少次询问。我们下面证明,O(n^2)次询问是必须的。
考虑一个二分图,左边n个顶点表示n个拼块,右边n个顶点表示拼图上残留的n个洞。现在,我只能询问指定的两顶点间是否有边,只有当交互库回答了n次yes后拼图才算完成。那么,作为交互库,你应该尽可能返回对游戏者不利的信息,让整个局面往最坏的方向发展。如果叫你来写这个交互库,你该怎么写?容易想到,只要有可能,我都返回no;除非某个时候一旦我再返回一次no,所有没被问过的边和返回过yes的边所组成的二分图不存在一个完全匹配时,我才可能返回yes。我们需要一个二分图存在完全匹配的充分条件来支持我们的这个算法。
考虑如下定理:如果一个二分图左边右边各有n个顶点,每个顶点都与对面至少n/2个顶点相连,则这个二分图一定存在一个完全匹配。定理的证明很简单。König定理告诉我们,二分图的最大匹配数应该等于最小点覆盖集,而一个图的最小点覆盖与最大点独立集是互补的,它们的和始终等于顶点数|V|(在这里|V|=2n)。因此我们只需要证明,上述二分图的最大点独立集不会超过n。假如我在左边选的顶点数不超过n/2个,则右边最多也只能选n/2个顶点(左边任一个点都已经使右边至少n/2个点废了);假如我左边选的顶点数超过了n/2个,则右边的顶点一个都不能选(右边每个点都连接了左边至少n/2个点,任选一个都会导致冲突)。总之,最大点独立集不可能超过n,但n显然是可以达到的(取同一边的所有点),那么最小点覆盖集也就是n,即二分图存在完全匹配。
有了这个定理,下面我就好办了:任何时候,只要每个顶点你都有半数以上的边没问过,我就可以放心大胆的回答no(因为这些没问过的边总可以组成一个完全匹配);一旦某个时刻有一个顶点被问过了n/2次,那么我就随便找一个完全匹配,把这个点“亮”出来,告诉你这个点应该和哪个点匹配(不计询问次数),然后把这两个匹配了的顶点从图中删去,继续刚才的操作。每次删除一对顶点都会顺带着删掉与它们相连的至少k/2条问过的边,其中k表示当时左边右边各剩下k个顶点。删掉了多少边就表示你曾问过了多少边,因此完成整个拼图你总共问过至少n/2 + (n-1)/2 + ... + 2/2 + 1/2条边,这个数量显然是O(n^2)的。
做人要厚道
转贴请注明出处
参考资料:http://www.brand.site.co.il/riddles/200710q.html
我们之前所有的排序算法都是给定了数据再进行排序,排序的效率很大程度上取决于数据的好坏。我们今天所介绍的是一个完全不同的排序方法,它可以在“暗箱”里对数据进行排序(即你不必知道实际数据是什么),换句话说这种排序方法不依赖于数据(Data-Independent),所有比较操作都与数据无关。你甚至可以立即忘掉前面的比较结果,因为对于所有可能的数据这类排序算法都能得到正确答案并且排序步骤完全相同。本文结束后再回过头来看这段话你将有更深的认识。
我们设置一个暗箱,暗箱左边有n个输入口,暗箱右边有n个输出口。我们需要设计一个暗箱使得,任意n个数从左边输进去,右边出来的都是有序的。图1显示了有4个输入的暗箱。

暗箱里唯一允许的元件叫做“比较器”(Comparator),每个比较器连接两个元素,当上面那个比下面那个大时它将交换两个元素的位置。也就是说,每经过一个比较器后,它的两端中较小的一个总是从上面出来,较大的总是到了下面。图2显示了一种包含4个比较器的暗箱系统。当输入数据3,1,4,2通过这个系统时,输出为1,3,2,4,如图3所示。这种暗箱结构叫做比较网络(Comparator Network)。如果对于任意一个输入数据,比较网络的输出都是有序的,那么这个比较网络就叫做排序网络(Sorting Network)。显然,我们例子中的比较网络不是一个排序网络,因为它不能通过3,1,4,2的检验。
现在,我们的第一个问题是,是否存在比较网络。就是说,有没有可能使得任意数据通过同一组比较器都能输出有序的结果。我们最初的想法当然是,把我们已知的什么排序算法改成这种形式。把原来那十种排序又翻出来看一遍,找一找哪些排序的比较操作是无条件的。运气不错,我们所学的第一个算法——冒泡排序,它的比较就是无条件的,不管数据怎样冒泡排序都是不断比较相邻元素并把较小的放到前面。冒泡排序是一个彻头彻尾的排序网络模型,我们可以立即画出冒泡排序所对应的排序网络(图4)。这是我们得到的第一个排序网络。我们通常不认为插入排序是排序网络,因为插入排序的比较次数取决于数据的有序程度。

传统的计算机一次只能处理一个比较。排序网络真正的研究价值在于,假如有机器可以同时处理多个比较器,排序的速度将大幅度提高。我们把比较器的位置稍微移动一下,把那些互不冲突(处理的元素不同)的比较器压缩到一层(Stage)(图5),这样整个排序过程压缩为了2n-3层。实现排序网络的机器可以在单位时间里并行处理同一层中所有的比较。此时,比较次数的多少对排序效率不起决定作用了,即使比较次数多一些但是排序网络的层次更少,效率也会更高一些。我们自然又想,排序网络需要的层数能否少于2n-3。我们想到,图5的左下角和右下角似乎有些空,我们期望能在这些位置加一些比较从而减少层数。图6给出了一个只有n层的排序网络,这叫做奇偶移项排序(Odd-even Transposition Sort)。我们下文将证明它确实是一个排序网络。这次的图很多,排版也很困难,累死我了。我把下面的图7也放到这里来了,不然到处都是图很难看。

给出一个比较网络,怎样判断它是不是一个排序网络?很遗憾,现在还没有找到一种好的算法。事实上,这个问题是一个NPC问题。注:这种说法是不准确的,因为目前还没有迹象表明这个问题是NP问题。准确的说法应该是,“判断某比较网络为排序网络”是Co-NP Complete,而“判断某比较网络不是排序网络”(即找到一个反例)才是NP Complete。
传统的做法是枚举所有n的排列来验证,一共要考虑n!种情况。下面我们介绍排序网络理论里最重要的结论:0-1原理(0-1 Principle)。使用这个原理来验证排序网络只需要考虑2^n种情况。0-1原理告诉我们,如果所有的01序列能够通过比较网络排出顺序,那么这足以说明该网络为排序网络。证明过程很简单。为了证明这个结论,我们证明它的逆否命题(逆否命题与原命题同真假):如果一个比较网络不是排序网络,那么至少存在一个01序列不能被排序。我们给出一种算法,这个算法可以把任何一个不能被排序的输入数据转化为一个不能被排序的01序列。
在最初的例子(图3)中,输入数据3,1,4,2的输出为1,3,2,4,没有成功地排出顺序,从而判断出该网络不是排序网络。这说明,输出结果中存在逆序对(左边某个数大于右边的某个数)。我们从输出结果中找出一个逆序对来。例子中,(3,2)就是我们要找的数。现在,我们把输入中所有小于数字3(左边那个数)的数都变成0,把所有大于等于3的数都变成1。这样,3,1,4,2就变成了1,0,1,0。显然,把得到的这个01序列输入进去,原来曾经发生过交换的地方现在仍然会交换,原来不曾交换的地方现在也同样不会发生交换(当两个0或两个1进行比较时,我们既可以认为它们不交换,也可以看成它们要互相交换,反正都一样)。最后,该01序列输出的结果中,本来3的位置现在还是1,原来2的位置现在仍然是0,逆序对仍然存在。因此,只要一个比较网络不是排序网络,那么总可以找到一个01序列不能被排序。等价地,如果所有的01序列都能被排序了,这个比较网络也就是排序网络了。
我们用0-1原理来证明奇偶移项排序的正确性。我们对n进行数学归纳证明。n=2时(一个“工”字)显然是排序网络。
图中是n=8的情况。我们假设对于所有n<=7,奇偶移项排序网络都是正确的。我们同时假定所有输入数字非0即1,下面我们说明n=8时所有的01序列都能被正确排序。
假设最后一个数是1(图7,在前面的),那么这个1将始终排在最后不参与任何交换操作,对前面7个数没有任何影响。除去无用的灰色部分,剩下的就是n=7这一规模较小的子排序网络,由归纳假设则n=8也是排序网络;

假设最后一个数是0(图8),那么在每一次比较中这个0都会被提到前面去(前面说过,两个0之间交不交换是一回事)。蓝色的箭头表示每个数跑到了什么位置。你会发现除最后一个数以外前7个数之间的比较器又构成了n=7的情况。
接下来,我们提出一些比较器个数为O(n*logn*logn)的排序网络。其中一种就是之前提到过的2^p*3^q增量Shell排序。这种增量排序的特点是每一趟排序中的每个数只与前面的数比较一次,因此它可以非常方便地转化为排序网络。图9就是一个n=8的Shell排序网络。Bitonic排序也可以做到O(n*logn*logn)的比较器个数,今天不介绍它。下面详细介绍奇偶归并排序网络。

奇偶归并排序网络也是一种比较器个数为O(n*logn*logn)的排序网络。它和归并排序几乎相同,不同的只是合并的过程。普通归并排序的O(n)合并过程显然是依赖于数据的,奇偶归并排序可以把这个合并过程改成非数据依赖型,但复杂度将变高。这个合并过程本身也是递归的。我们假设n是2的幂(不是的话可以在前面添0补足,这对复杂度的计算没有影响),算法首先把n个数中所有的奇数项和偶数项分别递归地合并,然后在排序后的第i个偶数项和第i+1个奇数项之间设立比较器。
假如1,4,6,8和2,3,7,9是两段已经有序的子序列,合并过程首先递归地合并1,6,2,7和4,8,3,9,这样原数列就变成了1,3,2,4,6,8,7,9。然后分别把(3,2),(4,6),(8,7)三对相邻元素中各自较小的那个交换到前面,完成合并操作。使用0-1原理证明这个结论出乎意料的简单:图10显示了n=16的情况,白色的方格代表一个0,灰色方格代表1。奇偶项分别排序后,偶数项1的个数最多比奇数项多出2个,我们设立的比较器可以考虑到所有的情况,不管什么情况都能让它最终变得有序。

由前面说过的结论,合并过程总共需要比较O(nlogn)次。归并排序递归一共有O(logn)层,每一层总的比较器个数不超过O(nlogn),因此总共O(n*logn*logn)。一个n=8的完整的奇偶归并排序网络如图11所示。
菜鸟献丑,漏洞百出。如果我有什么错误,各位大牛请指正。
Matrix67原创,转载请注明出处。
外部排序(External Sort)已经在这里提到过,不再说了。
所有排序的知识到这里说完了,下次再发布的就是数论相关内容了。数论部分将从进位制开始谈起。
我会一直写下去,本人活到什么时候写到什么时候写完为止。不过,这几天缓一下,我计划做一个PJBlog的单版面论坛模块。
那么,有什么方法可以不用比较就能排出顺序呢?借助Hash表的思想,多数人都能想出这样一种排序算法来。
我们假设给出的数字都在一定范围中,那么我们就可以开一个范围相同的数组,记录这个数字是否出现过。由于数字有可能有重复,因此Hash表的概念需要扩展,我们需要把数组类型改成整型,用来表示每个数出现的次数。
看这样一个例子,假如我们要对数列3 1 4 1 5 9 2 6 5 3 5 9进行排序。由于给定数字每一个都小于10,因此我们开一个0到9的整型数组T[i],记录每一个数出现了几次。读到一个数字x,就把对应的T[x]加一。
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
+---+---+---+---+---+---+---+---+---+---+
数字 i: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
+---+---+---+---+---+---+---+---+---+---+
出现次数T[i]: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |
+---+---+---+---+---+---+---+---+---+---+
最后,我们用一个指针从前往后扫描一遍,按照次序输出0到9,每个数出现了几次就输出几个。假如给定的数是n个大小不超过m的自然数,显然这个算法的复杂度是O(m+n)的。
我曾经以为,这就是线性时间排序了。后来我发现我错了。再后来,我发现我曾犯的错误是一个普遍的错误。很多人都以为上面的这个算法就是传说中的计数排序。问题出在哪里了?为什么它不是线性时间的排序算法?原因是,这个算法根本不是排序算法,它根本没有对原数据进行排序。
问题一:为什么说上述算法没有对数据进行排序?
STOP! You should think for a while.
我们班有很多MM。和身高相差太远的MM在一起肯定很别扭,接个吻都要弯腰才行(小猫矮死了)。为此,我希望给我们班的MM的身高排序。我们班MM的身高,再离谱也没有超过2米的,这很适合用我们刚才的算法。我们在黑板上画一个100到200的数组,MM依次自曝身高,我负责画“正”字统计人数。统计出来了,从小到大依次为141, 143, 143, 147, 152, 153, ...。这算哪门子排序?就一排数字对我有什么用,我要知道的是哪个MM有多高。我们仅仅把元素的属性值从小到大列了出来,但我们没有对元素本身进行排序。也就是说,我们需要知道输出结果的每个数值对应原数据的哪一个元素。下文提到的“排序算法的稳定性”也和属性值与实际元素的区别有关。
问题二:怎样将线性时间排序后的输出结果还原为原数据中的元素?
STOP! You should think for a while.
同样借助Hash表的思想,我们立即想到了类似于开散列的方法。我们用链表把属性值相同的元素串起来,挂在对应的T[i]上。每次读到一个数,在增加T[i]的同时我们把这个元素放进T[i]延伸出去的链表里。这样,输出结果时我们可以方便地获得原数据中的所有属性值为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 |
+---+---+---+---+---+---+---+---+---+---+
出现次数T[i]: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |
+---+o--+-o-+-o-+-o-+-o-+--o+---+---+-o-+
| | | | | | |
+--+ +-+ | | +-+ +---+ |
| | A[1] | | | A[6]
A[2] A[7] | A[3] A[5] A[8] |
| | | A[12]
A[4] A[10] A[9]
|
A[11]
形象地说,我们在地上摆10个桶,每个桶编一个号,然后把数据分门别类放在自己所属的桶里。这种排序算法叫做桶式排序(Bucket Sort)。本文最后你将看到桶式排序的另一个用途。
链表写起来比较麻烦,一般我们不使用它。我们有更简单的方法。
问题三:同样是输出元素本身,你能想出不用链表的其它算法么?
STOP! You should think for a while.
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
+---+---+---+---+---+---+---+---+---+---+
数字 i: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
+---+---+---+---+---+---+---+---+---+---+
出现次数T[i]: | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |
+---+---+---+---+---+---+---+---+---+---+
修改后的T[i]: | 0 | 2 | 3 | 5 | 6 | 9 | 10| 10| 10| 12|
+---+---+---+---+---+---+---+---+---+---+
所有数都读入后,我们修改T[i]数组的值,使得T[i]表示数字i可能的排名的最大值。比如,1最差排名第二,3最远可以排到第五。T数组的最后一个数应该等于输入数据的数字个数。修改T数组的操作可以用一次线性的扫描累加完成。
我们还需要准备一个输出数组。然后,我们从后往前扫描A数组,依照T数组的指示依次把原数据的元素直接放到输出数组中,同时T[i]的值减一。之所以从后往前扫描A数组,是因为这样输出结果才是稳定的。我们说一个排序算法是稳定的(Stable),当算法满足这样的性质:属性值相同的元素,排序后前后位置不变,本来在前面的现在仍然在前面。不要觉得排序算法是否具有稳定性似乎关系不大,排序的稳定性在下文的某个问题中将变得非常重要。你可以倒回去看看前面说的七种排序算法哪些是稳定的。
例子中,A数组最后一个数9所对应的T[9]=12,我们直接把9放在待输出序列中的第12个位置,然后T[9]变成11(这样下一次再出现9时就应该放在第11位)。
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9 <--
T[i]= 0, 2, 3, 5, 6, 9, 10, 10, 10, 11
Ans = _ _ _ _ _ _ _ _ _ _ _ 9
接下来的几步如下:
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5 <--
T[i]= 0, 2, 3, 5, 6, 8, 10, 10, 10, 11
Ans = _ _ _ _ _ _ _ _ 5 _ _ 9
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3 <--
T[i]= 0, 2, 3, 4, 6, 8, 10, 10, 10, 11
Ans = _ _ _ _ 3 _ _ _ 5 _ _ 9
A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5 <--
T[i]= 0, 2, 3, 4, 6, 7, 10, 10, 10, 11
Ans = _ _ _ _ 3 _ _ 5 5 _ _ 9
这种算法叫做计数排序(Counting Sort)。正确性和复杂度都是显然的。
问题四:给定数的数据范围大了该怎么办?
STOP! You should think for a while.
前面的算法只有在数据的范围不大时才可行,如果给定的数在长整范围内的话,这个算法是不可行的,因为你开不下这么大的数组。Radix排序(Radix Sort)解决了这个难题。
昨天我没事翻了一下初中(9班)时的同学录,回忆了一下过去。我把比较感兴趣的MM的生日列在下面(绝对真实)。如果列表中的哪个MM有幸看到了这篇日志(几乎不可能),左边的Support栏有我的电子联系方式,我想知道你们怎么样了。排名不分先后。
- 19880818
- 19880816
- 19890426
- 19880405
- 19890125
- 19881004
- 19881209
- 19890126
- 19890228
这就是我的数据了。现在,我要给这些数排序。假如我的电脑只能开出0..99的数组,那计数排序算法最多对两位数进行排序。我就把每个八位数两位两位地分成四段(图1),分别进行四次计数排序。地球人都知道月份相同时应该看哪一日,因此我们看月份的大小时应该事先保证日已经有序。换句话说,我们先对“最不重要”的部分进行排序。我们先对所有数的最后两位进行一次计数排序(图2)。注意观察1月26号的MM和4月26号的MM,本次排序中它们的属性值相同,由于计数排序是稳定的,因此4月份那个排完后依然在1月份那个的前头。接下来我们对百位和千位进行排序(图3)。你可以看到两个26日的MM在这一次排序中分出了大小,而月份相同的MM依然保持日数有序(因为计数排序是稳定的)。最后我们对年份排序(图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算法进行排序。但,要是给定的数据有无穷多位怎么办?有人说,这可能么。这是可能的,比如给定的数据是小数(更准确地说,实数)。基于比较的排序可以区分355/113和π哪个大,但你不知道Radix排序需要精确到哪一位。这下惨了,实数的出现把貌似高科技的线性时间排序打回了农业时代。这时,桶排序再度出山,挽救了线性时间排序悲惨的命运。
问题六:如何对实数进行线性时间排序?
STOP! You should think for a while.
我们把问题简化一下,给出的所有数都是0到1之间的小数。如果不是,也可以把所有数同时除以一个大整数从而转化为这种形式。我们依然设立若干个桶,比如,以小数点后面一位数为依据对所有数进行划分。我们仍然用链表把同一类的数串在一起,不同的是,每一个链表都是有序的。也就是说,每一次读到一个新的数都要进行一次插入排序。看我们的例子:
A[]= 0.12345, 0.111, 0.618, 0.9, 0.99999
+---+---+---+---+---+---+---+---+---+---+
十分位: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
+---+-o-+---+---+---+---+-o-+---+---+-o-+
| | |
A[2]=0.111 A[3]=0.618 A[4]=0.9
| |
A[1]=0.12345 A[5]=0.99999
假如再下一个读入的数是0.122222,这个数需要插入到十分位为1的那个链表里适当的位置。我们需要遍历该链表直到找到第一个比0.122222大的数,在例子中则应该插入到链表中A[2]和A[1]之间。最后,我们按顺序遍历所有链表,依次输出每个链表中的每个数。
这个算法显然是正确的,但复杂度显然不是线性。事实上,这种算法最坏情况下是O(n^2)的,因为当所有数的十分位都相同时算法就是一个插入排序。和原来一样,我们下面要计算算法的平均时间复杂度,我们希望这种算法的平均复杂度是线性的。
这次算平均复杂度我们用最笨的办法。我们将算出所有可能出现的情况的总时间复杂度,除以总的情况数,得到平均的复杂度是多少。
每个数都可能属于10个桶中的一个,n个数总的情况有10^n种。这个值是我们庞大的算式的分母部分。如果一个桶里有K个元素,那么只与这个桶有关的操作有O(K^2)次,它就是一次插入排序的操作次数。下面计算,在10^n种情况中,K0=1有多少种情况。K0=1表示,n个数中只有一个数在0号桶,其余n-1个数的十分位就只能在1到9中选择。那么K0=1的情况有C(n,1)*9^(n-1),而每个K0=1的情况在0号桶中将产生1^2的复杂度。类似地,Ki=p的情况数为C(n,p)*9^(n-p),复杂度总计为C(n,p)*9^(n-p)*p^2。枚举所有K的下标和p值,累加起来,这个算式大家应该能写出来了,但是这个……怎么算啊。别怕,我们是搞计算机的,拿出点和MO不一样的东西来。于是,Mathematica 5.0隆重登场,我做数学作业全靠它。它将帮我们化简这个复杂的式子。
我们遗憾地发现,虽然常数因子很小(只有0.1),但算法的平均复杂度仍然是平方的。等一下,1/10的那个10是我们桶的个数吗?那么我们为什么不把桶的个数弄大点?我们干脆用m来表示桶的个数,重新计算一次:
化简出来,操作次数为O(n+n^2/m)。发现了么,如果m=Θ(n)的话,平均复杂度就变成了O(n)。也就是说,当桶的个数等于输入数据的个数时,算法是平均线性的。
我们将在Hash表开散列的介绍中重新提到这个结论。
且慢,还有一个问题。10个桶以十分位的数字归类,那么n个桶用什么方法来分类呢?注意,分类的方法需要满足,一,一个数分到每个桶里的概率相同(这样才有我们上面的结论);二,所有桶里容纳元素的范围必须是连续的。根据这两个条件,我们有办法把所有数恰好分为n类。我们的输入数据不是都在0到1之间么?只需要看这些数乘以n的整数部分是多少就行了,读到一个数后乘以n取整得几就插入到几号桶里。这本质上相当于把区间[0,1)平均分成n份。
问题七:有没有复杂度低于线性的排序算法
STOP! You should think for a while.
我们从O(n^2)走向O(nlogn),又从O(nlogn)走向线性,每一次我们都讨论了复杂度下限的问题,根据讨论的结果提出了更优的算法。这次总算不行了,不可能有比线性还快的算法了,因为——你读入、输出数据至少就需要线性的时间。排序算法之旅在线性时间复杂度这一站终止了,所有十种排序算法到这里介绍完毕了。
文章有越写越长的趋势了,我检查起来也越来越累了。我又看了三遍,应该没问题了。群众的眼睛是雪亮的,恳请大家帮我找错。
Matrix67原创
转贴请注明出处
本文被华丽的分割线分为了四段。对于O(nlogn)的排序算法,我们详细介绍归并排序并证明归并排序的时间复杂度,然后简单介绍堆排序,之后给出快速排序的基本思想和复杂度证明。最后我们将证明,O(nlogn)在理论上已经达到了最优。学过OI的人一般都学过这些很基础的东西,大多数OIer们不必看了。为了保持系列文章的完整性,我还是花时间写了一下。
首先考虑一个简单的问题:如何在线性的时间内将两个有序队列合并为一个有序队列(并输出)?
A队列:1 3 5 7 9
B队列:1 2 7 8 9
看上面的例子,AB两个序列都是已经有序的了。在给出数据已经有序的情况下,我们会发现很多神奇的事,比如,我们将要输出的第一个数一定来自于这两个序列各自最前面的那个数。两个数都是1,那么我们随便取出一个(比如A队列的那个1)并输出:
A队列:1 3 5 7 9
B队列:1 2 7 8 9
输出:1
注意,我们取出了一个数,在原数列中删除这个数。删除操作是通过移动队首指针实现的,否则复杂度就高了。
现在,A队列打头的数变成3了,B队列的队首仍然是1。此时,我们再比较3和1哪个大并输出小的那个数:
A队列:1 3 5 7 9
B队列:1 2 7 8 9
输出:1 1
接下来的几步如下:
A队列:1 3 5 7 9 A队列:1 3 5 7 9 A队列:1 3 5 7 9 A队列:1 3 5 7 9
B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ==> B队列:1 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)的思想。首先我们把给出的数列平分为左右两段,然后对两段数列分别进行排序,最后用刚才的合并算法把这两段(已经排过序的)数列合并为一个数列。有人会问“对左右两段数列分别排序时用的什么排序”么?答案是:用归并排序。也就是说,我们递归地把每一段数列又分成两段进行上述操作。你不需要关心实际上是怎么操作的,我们的程序代码将递归调用该过程直到数列不能再分(只有一个数)为止。
初看这个算法时有人会误以为时间复杂度相当高。我们下面给出的一个图将用非递归的眼光来看归并排序的实际操作过程,供大家参考。我们可以借助这个图证明,归并排序算法的时间复杂度为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个数,表示成上图显然需要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)的分治算法。这个结论后面经常用。我们将在计算几何部分举一大堆类似的例子。
如果你第一次见到这么诡异的算法,你可能会对这个感兴趣。分治是递归的一种应用。这是我们第一次接触递归运算。下面说的快速排序也是用的递归的思想。递归程序的复杂度分析通常和上面一样,主定理(Master Theory)可以简化这个分析过程。主定理和本文内容离得太远,我们以后也不会用它,因此我们不介绍它,大家可以自己去查。有个名词在这里的话找学习资料将变得非常容易,我最怕的就是一个东西不知道叫什么名字,半天找不到资料。
归并排序有一个有趣的副产品。利用归并排序能够在O(nlogn)的时间里计算出给定序列里逆序对的个数。你可以用任何一种平衡二叉树来完成这个操作,但用归并排序统计逆序对更方便。我们讨论逆序对一般是说的一个排列中的逆序对,因此这里我们假设所有数不相同。假如我们想要数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 6
B队列: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队列剩的数目加进最终答案里。这样我们算出了所有“大的数在前一半,小的数在后一半”的情况,其余情况下的逆序对在这之前已经被递归地算过了。
============================华丽的分割线============================
堆排序(Heap Sort)利用了堆(Heap)这种数据结构(什么是堆?)。堆的插入操作是平均常数的,而删除一个根节点需要花费O(log n)的时间。因此,完成堆排序需要线性时间建立堆(把所有元素依次插入一个堆),然后用总共O(nlogn)的时间不断取出最小的那个数。只要堆会搞,堆排序就会搞。堆在那篇日志里有详细的说明,因此这里不重复说了。
============================华丽的分割线============================
快速排序(Quick Sort)也应用了递归的思想。我们想要把给定序列分成两段,并对这两段分别进行排序。一种不错的想法是,选取一个数作为“关键字”,并把其它数分割为两部分,把所有小于关键字的数都放在关键字的左边,大于关键字的都放在右边,然后递归地对左边和右边进行排序。把该区间内的所有数依次与关键字比较,我们就可以在线性的时间里完成分割的操作。完成分割操作有很多有技巧性的实现方法,比如最常用的一种是定义两个指针,一个从前往后找找到比关键字大的,一个从后往前找到比关键字小的,然后两个指针对应的元素交换位置并继续移动指针重复刚才的过程。这只是大致的方法,具体的实现还有很多细节问题。快速排序是我们最常用的代码之一,网上的快速排序代码五花八门,各种语言,各种风格的都有。大家可以随便找一个来看看,我说过了我们讲算法但不讲如何实现。NOIp很简单,很多人NOIp前就背了一个快速排序代码就上战场了。当时我把快速排序背完了,抓紧时间还顺便背了一下历史,免得晚上听写又不及格。
不像归并排序,快速排序的时间复杂度很难计算。我们可以看到,归并排序的复杂度最坏情况下也是O(nlogn)的,而快速排序的最坏情况是O(n^2)的。如果每一次选的关键字都是当前区间里最大(或最小)的数,那么这样将使得每一次的规模只减小一个数,这和插入排序、选择排序等平方级排序没有区别。这种情况不是不可能发生。如果你每次选择关键字都是选择的该区间的第一个数,而给你的数据恰好又是已经有序的,那你的快速排序就完蛋了。显然,最好情况是每一次选的数正好就是中位数,这将把该区间平分为两段,复杂度和前面讨论的归并排序一模一样。根据这一点,快速排序有一些常用的优化。比如,我们经常从数列中随机取一个数当作是关键字(而不是每次总是取固定位置上的数),从而尽可能避免某些特殊的数据所导致的低效。更好的做法是随机取三个数并选择这三个数的中位数作为关键字。而对三个数的随机取值反而将花费更多的时间,因此我们的这三个数可以分别取数列的头一个数、末一个数和正中间那个数。另外,当递归到了一定深度发现当前区间里的数只有几个或十几个时,继续递归下去反而费时,不如返回插入排序后的结果。这种方法同时避免了当数字太少时递归操作出错的可能。
下面我们证明,快速排序算法的平均复杂度为O(nlogn)。不同的书上有不同的解释方法,这里我选用算法导论上的讲法。它更有技巧性一些,更有趣一些,需要转几个弯才能想明白。
看一看快速排序的代码。正如我们提到过的那种分割方法,程序在经过若干次与关键字的比较后才进行一次交换,因此比较的次数比交换次数更多。我们通过证明一次快速排序中元素之间的比较次数平均为O(nlogn)来说明快速排序算法的平均复杂度。证明的关键在于,我们需要算出某两个元素在整个算法过程中进行过比较的概率。
我们举一个例子。假如给出了1到10这10个数,第一次选择关键字7将它们分成了{1,2,3,4,5,6}和{8,9,10}两部分,递归左边时我们选择了3作为关键字,使得左部分又被分割为{1,2}和{4,5,6}。我们看到,数字7与其它所有数都比较过一次,这样才能实现分割操作。同样地,1到6这6个数都需要与3进行一次比较(除了它本身之外)。然而,3和9决不可能相互比较过,2和6也不可能进行过比较,因为第一次出现在3和9,2和6之间的关键字把它们分割开了。也就是说,两个数A(i)和A(j)比较过,当且仅当第一个满足A(i)<=x<=A(j)的关键字x恰好就是A(i)或A(j) (假设A(i)比A(j)小)。我们称排序后第i小的数为Z(i),假设i<j,那么第一次出现在Z(i)和Z(j)之间的关键字恰好就是Z(i)或Z(j)的概率为2/(j-i+1),这是因为当Z(i)和Z(j)之间还不曾有过关键字时,Z(i)和Z(j)处于同一个待分割的区间,不管这个区间有多大,不管递归到哪里了,关键字的选择总是随机的。我们得到,Z(i)和Z(j)在一次快速排序中曾经比较过的概率为2/(j-i+1)。
现在有四个数,2,3,5,7。排序时,相邻的两个数肯定都被比较过,2和5、3和7都有2/3的概率被比较过,2和7之间被比较过有2/4的可能。也就是说,如果对这四个数做12次快速排序,那么2和3、3和5、5和7之间一共比较了12*3=36次,2和5、3和7之间总共比较了8*2=16次,2和7之间平均比较了6次。那么,12次排序中总的比较次数期望值为36+16+6=58。我们可以计算出单次的快速排序平均比较了多少次:58/12=29/6。其实,它就等于6项概率之和,1+1+1+2/3+2/3+2/4=29/6。这其实是与期望值相关的一个公式。
同样地,如果有n个数,那么快速排序平均需要的比较次数可以写成下面的式子。令k=j-i,我们能够最终得到比较次数的期望值为O(nlogn)。

这里用到了一个知识:1+1/2+1/3+...+1/n与log n增长速度相同,即Σ(1/n)=Θ(log n)。它的证明放在本文的最后。
在三种O(nlogn)的排序算法中,快速排序的理论复杂度最不理想,除了它以外今天说的另外两种算法都是以最坏情况O(nlogn)的复杂度进行排序。但实践上看快速排序效率最高(不然为啥叫快速排序呢),原因在于快速排序的代码比其它同复杂度的算法更简洁,常数时间更小。
快速排序也有一个有趣的副产品:快速选择给出的一些数中第k小的数。一种简单的方法是使用上述任一种O(nlogn)的算法对这些数进行排序并返回排序后数组的第k个元素。快速选择(Quick Select)算法可以在平均O(n)的时间完成这一操作。它的最坏情况同快速排序一样,也是O(n^2)。在每一次分割后,我们都可以知道比关键字小的数有多少个,从而确定了关键字在所有数中是第几小的。我们假设关键字是第m小。如果k=m,那么我们就找到了答案——第k小元素即该关键字。否则,我们递归地计算左边或者右边:当k<m时,我们递归地寻找左边的元素中第k小的;当k>m时,我们递归地寻找右边的元素中第k-m小的数。由于我们不考虑所有的数的顺序,只需要递归其中的一边,因此复杂度大大降低。复杂度平均线性,我们不再具体证了。
还有一种算法可以在最坏O(n)的时间里找出第k小元素。那是我见过的所有算法中最没有实用价值的算法。那个O(n)只有理论价值。
============================华丽的分割线============================
我们前面证明过,仅仅依靠交换相邻元素的操作,复杂度只能达到O(n^2)。于是,人们尝试交换距离更远的元素。当人们发现O(nlogn)的排序算法似乎已经是极限的时候,又是什么制约了复杂度的下界呢?我们将要讨论的是更底层的东西。我们仍然假设所有的数都不相等。
我们总是不断在数与数之间进行比较。你可以试试,只用4次比较绝对不可能给4个数排出顺序。每多进行一次比较我们就又多知道了一个大小关系,从4次比较中一共可以获知4个大小关系。4个大小关系共有2^4=16种组合方式,而4个数的顺序一共有4!=24种。也就是说,4次比较可能出现的结果数目不足以区分24种可能的顺序。更一般地,给你n个数叫你排序,可能的答案共有n!个,k次比较只能区分2^k种可能,于是只有2^k>=n!时才有可能排出顺序。等号两边取对数,于是,给n个数排序至少需要log2(n!)次。注意,我们并没有说明一定能通过log2(n!)次比较排出顺序。虽然2^5=32超过了4!,但这不足以说明5次比较一定足够。如何用5次比较确定4个数的大小关系还需要进一步研究。第一次例外发生在n=12的时候,虽然2^29>12!,但现已证明给12个数排序最少需要30次比较。我们可以证明log(n!)的增长速度与nlogn相同,即log(n!)=Θ(nlogn)。这是排序所需要的最少的比较次数,它给出了排序复杂度的一个下界。log(n!)=Θ(nlogn)的证明也附在本文最后。
这篇日志的第三题中证明log2(N)是最优时用到了几乎相同的方法。那种“用天平称出重量不同的那个球至少要称几次”一类题目也可以用这种方法来解决。事实上,这里有一整套的理论,它叫做信息论。信息论是由香农(Shannon)提出的。他用对数来表示信息量,用熵来表示可能的情况的随机性,通过运算可以知道你目前得到的信息能够怎样影响最终结果的确定。如果我们的信息量是以2为底的,那信息论就变成信息学了。从根本上说,计算机的一切信息就是以2为底的信息量(bits=binary digits),因此我们常说香农是数字通信之父。信息论和热力学关系密切,比如熵的概念是直接从热力学的熵定义引申过来的。和这个有关的东西已经严重偏题了,这里不说了,有兴趣可以去看《信息论与编码理论》。我对这个也很有兴趣,半懂不懂的,很想了解更多的东西,有兴趣的同志不妨加入讨论。物理学真的很神奇,利用物理学可以解决很多纯数学问题,我有时间的话可以举一些例子。我他妈的为啥要选文科呢。
后面将介绍的三种排序是线性时间复杂度,因为,它们排序时根本不是通过互相比较来确定大小关系的。
附1:Σ(1/n)=Θ(log n)的证明
首先我们证明,Σ(1/n)=O(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/2^k的后面2^k-1项全部扩大为1/2^k,使得这2^k个分式加起来是一个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,使得两个1/4加起来凑成一个1/2;再把1/5,1/6和1/7全部变成1/8,这样四个1/8加起来又是一个1/2。我们把所有1/2^k的前面2^k-1项全部缩小为1/2^k,使得这2^k个分式加起来是一个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^n,两边取对数我们得到log(n!)<log(n^n),而log(n^n)就等于nlogn。因此,O(nlogn)是log(n!)的复杂度上界。
然后我们证明,log(n!)=Ω(nlogn)。n!=n(n-1)(n-2)(n-3)....1,把前面一半的因子全部缩小到n/2,后面一半因子全部舍去,显然有n!>(n/2)^(n/2)。两边取对数,log(n!)>(n/2)log(n/2),后者即Ω(nlogn)。因此,Ω(nlogn)是log(n!)的复杂度下界。
今天写到这里了,大家帮忙校对哦
Matrix67原创
转贴请注明出处
今天我正式开始按照我的目录写我的OI心得了。我要把我所有学到的OI知识传给以后千千万万的OIer。以前写过的一些东西不重复写了,但我最后将会重新整理,使之成为一个完整的教程。
按照我的目录,讲任何东西之前我都会先介绍时间复杂度的相关知识,以后动不动就会扯到这个东西。这个已经写过了,你可以在这里看到那篇又臭又长的文章。在讲排序算法的过程中,我们将始终围绕时间复杂度的内容进行说明。
我把这篇文章称之为“从零开始学算法”,因为排序算法是最基础的算法,介绍算法时从各种排序算法入手是最好不过的了。
给出n个数,怎样将它们从小到大排序?下面一口气讲三种常用的算法,它们是最简单的、最显然的、最容易想到的。选择排序(Selection Sort)是说,每次从数列中找出一个最小的数放到最前面来,再从剩下的n-1个数中选择一个最小的,不断做下去。插入排序(Insertion Sort)是,每次从数列中取一个还没有取出过的数,并按照大小关系插入到已经取出的数中使得已经取出的数仍然有序。冒泡排序(Bubble Sort)分为若干趟进行,每一趟排序从前往后比较每两个相邻的元素的大小(因此一趟排序要比较n-1对位置相邻的数)并在每次发现前面的那个数比紧接它后的数大时交换位置;进行足够多趟直到某一趟跑完后发现这一趟没有进行任何交换操作(最坏情况下要跑n-1趟,这种情况在最小的数位于给定数列的最后面时发生)。事实上,在第一趟冒泡结束后,最后面那个数肯定是最大的了,于是第二次只需要对前面n-1个数排序,这又将把这n-1个数中最小的数放到整个数列的倒数第二个位置。这样下去,冒泡排序第i趟结束后后面i个数都已经到位了,第i+1趟实际上只考虑前n-i个数(需要的比较次数比前面所说的n-1要小)。这相当于用数学归纳法证明了冒泡排序的正确性:实质与选择排序相同。上面的三个算法描述可能有点模糊了,没明白的话网上找资料,代码和动画演示遍地都是。
这三种算法非常容易理解,因为我们生活当中经常在用。比如,班上的MM搞选美活动,有人叫我给所有MM排个名。我们通常会用选择排序,即先找出自己认为最漂亮的,然后找第二漂亮的,然后找第三漂亮的,不断找剩下的人中最满意的。打扑克牌时我们希望抓完牌后手上的牌是有序的,三个8挨在一起,后面紧接着两个9。这时,我们会使用插入排序,每次拿到一张牌后把它插入到手上的牌中适当的位置。什么时候我们会用冒泡排序呢?比如,体育课上从矮到高排队时,站队完毕后总会有人出来,比较挨着的两个人的身高,指挥到:你们俩调换一下,你们俩换一下。
这是很有启发性的。这告诉我们,什么时候用什么排序最好。当人们渴望先知道排在前面的是谁时,我们用选择排序;当我们不断拿到新的数并想保持已有的数始终有序时,我们用插入排序;当给出的数列已经比较有序,只需要小幅度的调整一下时,我们用冒泡排序。
我们来算一下最坏情况下三种算法各需要多少次比较和赋值操作。
选择排序在第i次选择时赋值和比较都需要n-i次(在n-i+1个数中选一个出来作为当前最小值,其余n-i个数与当前最小值比较并不断更新当前最小值),然后需要一次赋值操作。总共需要n(n-1)/2次比较与n(n-1)/2+n次赋值。
插入排序在第i次寻找插入位置时需要最多i-1次比较(从后往前找到第一个比待插入的数小的数,最坏情况发生在这个数是所有已经取出的数中最小的一个的时候),在已有数列中给新的数腾出位置需要i-1次赋值操作来实现,还需要两次赋值借助临时变量把新取出的数搬进搬出。也就是说,最坏情况下比较需要n(n-1)/2次,赋值需要n(n-1)/2+2n次。我这么写有点误导人,大家不要以为程序的实现用了两个数组哦,其实一个数组就够了,看看上面的演示就知道了。我只说算法,一般不写如何实现。学算法的都是强人,知道算法了都能写出一个漂亮的代码来。
冒泡排序第i趟排序需要比较n-i次,n-1趟排序总共n(n-1)/2次。给出的序列逆序排列是最坏的情况,这时每一次比较都要进行交换操作。一次交换操作需要3次赋值实现,因此冒泡排序最坏情况下需要赋值3n(n-1)/2次。
按照渐进复杂度理论,忽略所有的常数,三种排序的最坏情况下复杂度都是一样的:O(n^2)。但实际应用中三种排序的效率并不相同。实践证明(政治考试时每道大题都要用这四个字),插入排序是最快的(虽然最坏情况下与选择排序相当甚至更糟),因为每一次插入时寻找插入的位置多数情况只需要与已有数的一部分进行比较(你可能知道这还能二分)。你或许会说冒泡排序也可以在半路上完成,还没有跑到第n-1趟就已经有序。但冒泡排序的交换操作更费时,而插入排序中找到了插入的位置后移动操作只需要用赋值就能完成(你可能知道这还能用move)。本文后面将介绍的一种算法就利用插入排序的这些优势。
我们证明了,三种排序方法在最坏情况下时间复杂度都是O(n^2)。但大家想过吗,这只是最坏情况下的。在很多时候,复杂度没有这么大,因为插入和冒泡在数列已经比较有序的情况下需要的操作远远低于n^2次(最好情况下甚至是线性的)。抛开选择排序不说(因为它的复杂度是“死”的,对于选择排序没有什么“好”的情况),我们下面探讨插入排序和冒泡排序在特定数据和平均情况下的复杂度。
你会发现,如果把插入排序中的移动赋值操作看作是把当前取出的元素与前面取出的且比它大的数逐一交换,那插入排序和冒泡排序对数据的变动其实都是相邻元素的交换操作。下面我们说明,若只能对数列中相邻的数进行交换操作,如何计算使得n个数变得有序最少需要的交换次数。
我们定义逆序对的概念。假设我们要把数列从小到大排序,一个逆序对是指的在原数列中,左边的某个数比右边的大。也就是说,如果找到了某个i和j使得i<j且Ai>Aj,我们就说我们找到了一个逆序对。比如说,数列3,1,4,2中有三个逆序对,而一个已经有序的数列逆序对个数为0。我们发现,交换两个相邻的数最多消除一个逆序对,且冒泡排序(或插入排序)中的一次交换恰好能消除一个逆序对。那么显然,原数列中有多少个逆序对冒泡排序(或插入排序)就需要多少次交换操作,这个操作次数不可能再少。
若给出的n个数中有m个逆序对,插入排序的时间复杂度可以说是O(m+n)的,而冒泡排序不能这么说,因为冒泡排序有很多“无用”的比较(比较后没有交换),这些无用的比较超过了O(m+n)个。从这个意义上说,插入排序仍然更为优秀,因为冒泡排序的复杂度要受到它跑的趟数的制约。一个典型的例子是这样的数列:8, 2, 3, 4, 5, 6, 7, 1。在这样的输入数据下插入排序的优势非常明显,冒泡排序只能哭着喊上天不公。
然而,我们并不想计算排序算法对于某个特定数据的效率。我们真正关心的是,对于所有可能出现的数据,算法的平均复杂度是多少。不用激动了,平均复杂度并不会低于平方。下面证明,两种算法的平均复杂度仍然是O(n^2)的。
我们仅仅证明算法需要的交换次数平均为O(n^2)就足够了。前面已经说过,它们需要的交换次数与逆序对的个数相同。我们将证明,n个数的数列中逆序对个数平均O(n^2)个。
计算的方法是十分巧妙的。如果把给出的数列反过来(从后往前倒过来写),你会发现原来的逆序对现在变成顺序的了,而原来所有的非逆序对现在都成逆序了。正反两个数列的逆序对个数加起来正好就是数列所有数对的个数,它等于n(n-1)/2。于是,平均每个数列有n(n-1)/4个逆序对。忽略常数,逆序对平均个数O(n^2)。
上面的讨论启示我们,要想搞出一个复杂度低于平方级别的排序算法,我们需要想办法能把离得老远的两个数进行操作。
人们想啊想啊想啊,怎么都想不出怎样才能搞出复杂度低于平方的算法。后来,英雄出现了,Donald Shell发明了一种新的算法,我们将证明它的复杂度最坏情况下也没有O(n^2) (似乎有人不喜欢研究正确性和复杂度的证明,我会用实例告诉大家,这些证明是非常有意思的)。他把这种算法叫做Shell增量排序算法(大家常说的希尔排序)。
Shell排序算法依赖一种称之为“排序增量”的数列,不同的增量将导致不同的效率。假如我们对20个数进行排序,使用的增量为1,3,7。那么,我们首先对这20个数进行“7-排序”(7-sortedness)。所谓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 <-- 原数列
3 3 2 0 5 1 5 7 4 4 0 6 1 6 8 7 9 9 8 2 <-- 7-排序后
0 0 1 1 2 2 3 3 4 4 5 6 5 6 8 7 7 9 8 9 <-- 3-排序后
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 <-- 1-排序后(完成)
在每一趟、每一组的排序中我们总是使用插入排序。仔细观察上面的例子你会发现是什么导致了Shell排序的高效。对,每一趟排序将使得数列部分有序,从而使得以后的插入排序很快找到插入位置。我们下面将紧紧围绕这一点来证明Shell排序算法的时间复杂度上界。
只要排序增量的第一个数是1,Shell排序算法就是正确的。但是不同的增量将导致不同的时间复杂度。我们上面例子中的增量(1, 3, 7, 15, 31, ..., 2^k-1)是使用最广泛的增量序列之一,可以证明使用这个增量的时间复杂度为O(n√n)。这个证明很简单,大家可以参看一些其它的资料,我们今天不证明它。今天我们证明,使用增量1, 2, 3, 4, 6, 8, 9, 12, 16, ..., 2^p*3^q,时间复杂度为O(n*(log n)^2)。
很显然,任何一个大于1的正整数都可以表示为2x+3y,其中x和y是非负整数。于是,如果一个数列已经是2-排序的且是3-排序的,那么对于此时数列中的每一个数A(i),它的左边比它大的只有可能是A(i-1)。A2绝对不可能比A12大,因为10可以表示为两个2和两个3的和,则A2<A4<A6<A9<A12。那么,在这个增量中的1-排序时每个数找插入位置只需要比较一次。一共有n个数,所以1-排序是O(n)的。事实上,这个增量中的2-排序也是O(n),因为在2-排序之前,这个数列已经是4-排序且6-排序过的,只看数列的奇数项或者偶数项(即单看每一组)的话就又成了刚才的样子。这个增量序列巧妙就巧妙在,如果我们要进行h-排序,那么它一定是2h-排序过且3h-排序过,于是处理每个数A(i)的插入时就只需要和A(i-h)进行比较。这个结论对于最开始几次(h值较大时)的h-排序同样成立,当2h、3h大于n时,按照定义,我们也可以认为数列是2h-排序和3h-排序的,这并不影响上述结论的正确性(你也可以认为h太大以致于排序时每一组里的数字不超过3个,属于常数级)。现在,这个增量中的每一趟排序都是O(n)的,我们只需要数一下一共跑了多少趟。也就是说,我们现在只需要知道小于n的数中有多少个数具有2^p*3^q的形式。要想2^p*3^q不超过n,p的取值最多O(log n)个,q的取值最多也是O(log n)个,两两组合的话共有O(logn*logn)种情况。于是,这样的增量排序需要跑O((log n)^2)趟,每一趟的复杂度O(n),总的复杂度为O(n*(log n)^2)。早就说过了,证明时间复杂度其实很有意思。
我们自然会想,有没有能使复杂度降到O(nlogn)甚至更低的增量序列。很遗憾,现在没有任何迹象表明存在O(nlogn)的增量排序。但事实上,很多时候Shell排序的实际效率超过了O(nlogn)的排序算法。
后面我们将介绍三种O(nlogn)的排序算法和三种线性时间的排序算法。最后我们将以外部排序和排序网络结束这一章节。
很多人问到我关于转贴的问题。我欢迎除商业目的外任何形式的转贴(论坛、Blog、Wiki、个人网站、PodCast,甚至做成ppt、pdf),但一定要注明出处,最好保留原始链接。我的网站需要点反向链接才能在网络中生存下去,大家也都可以关注并且推广这个Blog。我一直支持cc版权协议,因此发现了文章中的问题或者想要补充什么东西尽管提出来,好让更多的人学习到好东西。我昨天看Blog上原来写的一些东西,居然连着发现了几个错误式子和错别字,好奇大家居然没有提出来。发现了问题真的要告诉我,即使格式有点问题也要说一下,决不能让它那么错着。另外有什么建议或想法也请说一下,我希望听到不同的声音不同的见解,好让我决定这类文章以后的发展方向。
Matrix67原创
转贴请注明出处
如果机房马上要关门了,或者你急着要和MM约会,请直接跳到第六个自然段。
我们这里说的KMP不是拿来放电影的(虽然我很喜欢这个软件),而是一种算法。KMP算法是拿来处理字符串匹配的。换句话说,给你两个字符串,你需要回答,B串是否是A串的子串(A串是否包含B串)。比如,字符串A="I'm matrix67",字符串B="matrix",我们就说B是A的子串。你可以委婉地问你的MM:“假如你要向你喜欢的人表白的话,我的名字是你的告白语中的子串吗?”
解决这类问题,通常我们的方法是枚举从A串的什么位置起开始与B匹配,然后验证是否匹配。假如A串长度为n,B串长度为m,那么这种方法的复杂度是O (mn)的。虽然很多时候复杂度达不到mn(验证时只看头一两个字母就发现不匹配了),但我们有许多“最坏情况”,比如,A= "aaaaaaaaaaaaaaaaaaaaaaaaaab",B="aaaaaaaab"。我们将介绍的是一种最坏情况下O(n)的算法(这里假设 m<=n),即传说中的KMP算法。
之所以叫做KMP,是因为这个算法是由Knuth、Morris、Pratt三个提出来的,取了这三个人的名字的头一个字母。这时,或许你突然明白了AVL 树为什么叫AVL,或者Bellman-Ford为什么中间是一杠不是一个点。有时一个东西有七八个人研究过,那怎么命名呢?通常这个东西干脆就不用人名字命名了,免得发生争议,比如“3x+1问题”。扯远了。
个人认为KMP是最没有必要讲的东西,因为这个东西网上能找到很多资料。但网上的讲法基本上都涉及到“移动(shift)”、“Next函数”等概念,这非常容易产生误解(至少一年半前我看这些资料学习KMP时就没搞清楚)。在这里,我换一种方法来解释KMP算法。
假如,A="abababaababacb",B="ababacb",我们来看看KMP是怎么工作的。我们用两个指针i和j分别表示,A[i-j+ 1..i]与B[1..j]完全相等。也就是说,i是不断增加的,随着i的增加j相应地变化,且j满足以A[i]结尾的长度为j的字符串正好匹配B串的前 j个字符(j当然越大越好),现在需要检验A[i+1]和B[j+1]的关系。当A[i+1]=B[j+1]时,i和j各加一;什么时候j=m了,我们就说B是A的子串(B串已经整完了),并且可以根据这时的i值算出匹配的位置。当A[i+1]<>B[j+1],KMP的策略是调整j的位置(减小j值)使得A[i-j+1..i]与B[1..j]保持匹配且新的B[j+1]恰好与A[i+1]匹配(从而使得i和j能继续增加)。我们看一看当 i=j=5时的情况。
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7
此时,A[6]<>B[6]。这表明,此时j不能等于5了,我们要把j改成比它小的值j'。j'可能是多少呢?仔细想一下,我们发现,j'必须要使得B[1..j]中的头j'个字母和末j'个字母完全相等(这样j变成了j'后才能继续保持i和j的性质)。这个j'当然要越大越好。在这里,B [1..5]="ababa",头3个字母和末3个字母都是"aba"。而当新的j为3时,A[6]恰好和B[4]相等。于是,i变成了6,而j则变成了 4:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7
从上面的这个例子,我们可以看到,新的j可以取多少与i无关,只与B串有关。我们完全可以预处理出这样一个数组P[j],表示当匹配到B数组的第j个字母而第j+1个字母不能匹配了时,新的j最大是多少。P[j]应该是所有满足B[1..P[j]]=B[j-P[j]+1..j]的最大值。
再后来,A[7]=B[5],i和j又各增加1。这时,又出现了A[i+1]<>B[j+1]的情况:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7
由于P[5]=3,因此新的j=3:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7
这时,新的j=3仍然不能满足A[i+1]=B[j+1],此时我们再次减小j值,将j再次更新为P[3]:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 1 2 3 4 5 6 7
现在,i还是7,j已经变成1了。而此时A[8]居然仍然不等于B[j+1]。这样,j必须减小到P[1],即0:
i = 1 2 3 4 5 6 7 8 9 ……
A = a b a b a b a a b a b …
B = a b a b a c b
j = 0 1 2 3 4 5 6 7
终于,A[8]=B[1],i变为8,j为1。事实上,有可能j到了0仍然不能满足A[i+1]=B[j+1](比如A[8]="d"时)。因此,准确的说法是,当j=0了时,我们增加i值但忽略j直到出现A[i]=B[1]为止。
这个过程的代码很短(真的很短),我们在这里给出:
j:=0;
for i:=1 to n do
begin
while (j>0) and (B[j+1]<>A[i]) do j:=P[j];
if B[j+1]=A[i] then j:=j+1;
if j=m then
begin
writeln('Pattern occurs with shift ',i-m);
j:=P[j];
end;
end;
最后的j:=P[j]是为了让程序继续做下去,因为我们有可能找到多处匹配。
这个程序或许比想像中的要简单,因为对于i值的不断增加,代码用的是for循环。因此,这个代码可以这样形象地理解:扫描字符串A,并更新可以匹配到B的什么位置。
现在,我们还遗留了两个重要的问题:一,为什么这个程序是线性的;二,如何快速预处理P数组。
为什么这个程序是O(n)的?其实,主要的争议在于,while循环使得执行次数出现了不确定因素。我们将用到时间复杂度的摊还分析中的主要策略,简单地说就是通过观察某一个变量或函数值的变化来对零散的、杂乱的、不规则的执行次数进行累计。KMP的时间复杂度分析可谓摊还分析的典型。我们从上述程序的j 值入手。每一次执行while循环都会使j减小(但不能减成负的),而另外的改变j值的地方只有第五行。每次执行了这一行,j都只能加1;因此,整个过程中j最多加了n个1。于是,j最多只有n次减小的机会(j值减小的次数当然不能超过n,因为j永远是非负整数)。这告诉我们,while循环总共最多执行了n次。按照摊还分析的说法,平摊到每次for循环中后,一次for循环的复杂度为O(1)。整个过程显然是O(n)的。这样的分析对于后面P数组预处理的过程同样有效,同样可以得到预处理过程的复杂度为O(m)。
预处理不需要按照P的定义写成O(m^2)甚至O(m^3)的。我们可以通过P[1],P[2],...,P[j-1]的值来获得P[j]的值。对于刚才的B="ababacb",假如我们已经求出了P[1],P[2],P[3]和P[4],看看我们应该怎么求出P[5]和P[6]。P[4]=2,那么P [5]显然等于P[4]+1,因为由P[4]可以知道,B[1,2]已经和B[3,4]相等了,现在又有B[3]=B[5],所以P[5]可以由P[4] 后面加一个字符得到。P[6]也等于P[5]+1吗?显然不是,因为B[ P[5]+1 ]<>B[6]。那么,我们要考虑“退一步”了。我们考虑P[6]是否有可能由P[5]的情况所包含的子串得到,即是否P[6]=P[ P[5] ]+1。这里想不通的话可以仔细看一下:
1 2 3 4 5 6 7
B = a b a b a c b
P = 0 0 1 2 3 ?
P[5]=3是因为B[1..3]和B[3..5]都是"aba";而P[3]=1则告诉我们,B[1]、B[3]和B[5]都是"a"。既然P[6]不能由P[5]得到,或许可以由P[3]得到(如果B[2]恰好和B[6]相等的话,P[6]就等于P[3]+1了)。显然,P[6]也不能通过P[3]得到,因为B[2]<>B[6]。事实上,这样一直推到P[1]也不行,最后,我们得到,P[6]=0。
怎么这个预处理过程跟前面的KMP主程序这么像呢?其实,KMP的预处理本身就是一个B串“自我匹配”的过程。它的代码和上面的代码神似:
P[1]:=0;
j:=0;
for i:=2 to m do
begin
while (j>0) and (B[j+1]<>B[i]) do j:=P[j];
if B[j+1]=B[i] then j:=j+1;
P[i]:=j;
end;
最后补充一点:由于KMP算法只预处理B串,因此这种算法很适合这样的问题:给定一个B串和一群不同的A串,问B是哪些A串的子串。
串匹配是一个很有研究价值的问题。事实上,我们还有后缀树,自动机等很多方法,这些算法都巧妙地运用了预处理,从而可以在线性的时间里解决字符串的匹配。我们以后来说。
昨天发现一个特别晕的事,知道怎么去掉BitComet的广告吗?把界面语言设成英文就行了。
还有,金山词霸和Dr.eye都可以去自杀了,Babylon素王道。
Matrix67原创
转贴请注明出处
这或许是众多OIer最大的误区之一。
你会经常看到网上出现“这怎么做,这不是NP问题吗”、“这个只有搜了,这已经被证明是NP问题了”之类的话。你要知道,大多数人此时所说的NP问题其实都是指的NPC问题。他们没有搞清楚NP问题和NPC问题的概念。NP问题并不是那种“只有搜才行”的问题,NPC问题才是。好,行了,基本上这个误解已经被澄清了。下面的内容都是在讲什么是P问题,什么是NP问题,什么是NPC问题,你如果不是很感兴趣就可以不看了。接下来你可以看到,把NP问题当成是 NPC问题是一个多大的错误。
还是先用几句话简单说明一下时间复杂度。时间复杂度并不是表示一个程序解决问题需要花多少时间,而是当问题规模扩大后,程序需要的时间长度增长得有多快。也就是说,对于高速处理数据的计算机来说,处理某一个特定数据的效率不能衡量一个程序的好坏,而应该看当这个数据的规模变大到数百倍后,程序运行时间是否还是一样,或者也跟着慢了数百倍,或者变慢了数万倍。不管数据有多大,程序处理花的时间始终是那么多的,我们就说这个程序很好,具有O(1)的时间复杂度,也称常数级复杂度;数据规模变得有多大,花的时间也跟着变得有多长,这个程序的时间复杂度就是O(n),比如找n个数中的最大值;而像冒泡排序、插入排序等,数据扩大2倍,时间变慢4倍的,属于O(n^2)的复杂度。还有一些穷举类的算法,所需时间长度成几何阶数上涨,这就是O(a^n)的指数级复杂度,甚至O(n!)的阶乘级复杂度。不会存在O(2*n^2)的复杂度,因为前面的那个“2”是系数,根本不会影响到整个程序的时间增长。同样地,O (n^3+n^2)的复杂度也就是O(n^3)的复杂度。因此,我们会说,一个O(0.01*n^3)的程序的效率比O(100*n^2)的效率低,尽管在n很小的时候,前者优于后者,但后者时间随数据规模增长得慢,最终O(n^3)的复杂度将远远超过O(n^2)。我们也说,O(n^100)的复杂度小于O(1.01^n)的复杂度。
容易看出,前面的几类复杂度被分为两种级别,其中后者的复杂度无论如何都远远大于前者:一种是O(1),O(log(n)),O(n^a)等,我们把它叫做多项式级的复杂度,因为它的规模n出现在底数的位置;另一种是O(a^n)和O(n!)型复杂度,它是非多项式级的,其复杂度计算机往往不能承受。当我们在解决一个问题时,我们选择的算法通常都需要是多项式级的复杂度,非多项式级的复杂度需要的时间太多,往往会超时,除非是数据规模非常小。
自然地,人们会想到一个问题:会不会所有的问题都可以找到复杂度为多项式级的算法呢?很遗憾,答案是否定的。有些问题甚至根本不可能找到一个正确的算法来,这称之为“不可解问题”(Undecidable Decision Problem)。The Halting Problem就是一个著名的不可解问题,在我的Blog上有过专门的介绍和证明。再比如,输出从1到n这n个数的全排列。不管你用什么方法,你的复杂度都是阶乘级,因为你总得用阶乘级的时间打印出结果来。有人说,这样的“问题”不是一个“正规”的问题,正规的问题是让程序解决一个问题,输出一个“YES”或“NO”(这被称为判定性问题),或者一个什么什么的最优值(这被称为最优化问题)。那么,根据这个定义,我也能举出一个不大可能会有多项式级算法的问题来:Hamilton回路。问题是这样的:给你一个图,问你能否找到一条经过每个顶点一次且恰好一次(不遗漏也不重复)最后又走回来的路(满足这个条件的路径叫做Hamilton回路)。这个问题现在还没有找到多项式级的算法。事实上,这个问题就是我们后面要说的NPC问题。
下面引入P类问题的概念:如果一个问题可以找到一个能在多项式的时间里解决它的算法,那么这个问题就属于P问题。P是英文单词多项式的第一个字母。哪些问题是P类问题呢?通常NOI和NOIP不会出不属于P类问题的题目。我们常见到的一些信息奥赛的题目都是P问题。道理很简单,一个用穷举换来的非多项式级时间的超时程序不会涵盖任何有价值的算法。
接下来引入NP问题的概念。这个就有点难理解了,或者说容易理解错误。在这里强调(回到我竭力想澄清的误区上),NP问题不是非P类问题。NP问题是指可以在多项式的时间里验证一个解的问题。NP问题的另一个定义是,可以在多项式的时间里猜出一个解的问题。比方说,我RP很好,在程序中需要枚举时,我可以一猜一个准。现在某人拿到了一个求最短路径的问题,问从起点到终点是否有一条小于100个单位长度的路线。它根据数据画好了图,但怎么也算不出来,于是来问我:你看怎么选条路走得最少?我说,我RP很好,肯定能随便给你指条很短的路出来。然后我就胡乱画了几条线,说就这条吧。那人按我指的这条把权值加起来一看,嘿,神了,路径长度98,比100小。于是答案出来了,存在比100小的路径。别人会问他这题怎么做出来的,他就可以说,因为我找到了一个比100 小的解。在这个题中,找一个解很困难,但验证一个解很容易。验证一个解只需要O(n)的时间复杂度,也就是说我可以花O(n)的时间把我猜的路径的长度加出来。那么,只要我RP好,猜得准,我一定能在多项式的时间里解决这个问题。我猜到的方案总是最优的,不满足题意的方案也不会来骗我去选它。这就是NP问题。当然有不是NP问题的问题,即你猜到了解但是没用,因为你不能在多项式的时间里去验证它。下面我要举的例子是一个经典的例子,它指出了一个目前还没有办法在多项式的时间里验证一个解的问题。很显然,前面所说的Hamilton回路是NP问题,因为验证一条路是否恰好经过了每一个顶点非常容易。但我要把问题换成这样:试问一个图中是否不存在Hamilton回路。这样问题就没法在多项式的时间里进行验证了,因为除非你试过所有的路,否则你不敢断定它“没有Hamilton回路”。
之所以要定义NP问题,是因为通常只有NP问题才可能找到多项式的算法。我们不会指望一个连多项式地验证一个解都不行的问题存在一个解决它的多项式级的算法。相信读者很快明白,信息学中的号称最困难的问题——“NP问题”,实际上是在探讨NP问题与P类问题的关系。
很显然,所有的P类问题都是NP问题。也就是说,能多项式地解决一个问题,必然能多项式地验证一个问题的解——既然正解都出来了,验证任意给定的解也只需要比较一下就可以了。关键是,人们想知道,是否所有的NP问题都是P类问题。我们可以再用集合的观点来说明。如果把所有P类问题归为一个集合P中,把所有 NP问题划进另一个集合NP中,那么,显然有P属于NP。现在,所有对NP问题的研究都集中在一个问题上,即究竟是否有P=NP?通常所谓的“NP问题”,其实就一句话:证明或推翻P=NP。
NP问题一直都是信息学的巅峰。巅峰,意即很引人注目但难以解决。在信息学研究中,这是一个耗费了很多时间和精力也没有解决的终极问题,好比物理学中的大统一和数学中的歌德巴赫猜想等。
目前为止这个问题还“啃不动”。但是,一个总的趋势、一个大方向是有的。人们普遍认为,P=NP不成立,也就是说,多数人相信,存在至少一个不可能有多项式级复杂度的算法的NP问题。人们如此坚信P≠NP是有原因的,就是在研究NP问题的过程中找出了一类非常特殊的NP问题叫做NP-完全问题,也即所谓的 NPC问题。C是英文单词“完全”的第一个字母。正是NPC问题的存在,使人们相信P≠NP。下文将花大量篇幅介绍NPC问题,你从中可以体会到NPC问题使P=NP变得多么不可思议。
为了说明NPC问题,我们先引入一个概念——约化(Reducibility,有的资料上叫“归约”)。
简单地说,一个问题A可以约化为问题B的含义即是,可以用问题B的解法解决问题A,或者说,问题A可以“变成”问题B。《算法导论》上举了这么一个例子。比如说,现在有两个问题:求解一个一元一次方程和求解一个一元二次方程。那么我们说,前者可以约化为后者,意即知道如何解一个一元二次方程那么一定能解出一元一次方程。我们可以写出两个程序分别对应两个问题,那么我们能找到一个“规则”,按照这个规则把解一元一次方程程序的输入数据变一下,用在解一元二次方程的程序上,两个程序总能得到一样的结果。这个规则即是:两个方程的对应项系数不变,一元二次方程的二次项系数为0。按照这个规则把前一个问题转换成后一个问题,两个问题就等价了。同样地,我们可以说,Hamilton回路可以约化为TSP问题(Travelling Salesman Problem,旅行商问题):在Hamilton回路问题中,两点相连即这两点距离为0,两点不直接相连则令其距离为1,于是问题转化为在TSP问题中,是否存在一条长为0的路径。Hamilton回路存在当且仅当TSP问题中存在长为0的回路。
“问题A可约化为问题B”有一个重要的直观意义:B的时间复杂度高于或者等于A的时间复杂度。也就是说,问题A不比问题B难。这很容易理解。既然问题A能用问题B来解决,倘若B的时间复杂度比A的时间复杂度还低了,那A的算法就可以改进为B的算法,两者的时间复杂度还是相同。正如解一元二次方程比解一元一次方程难,因为解决前者的方法可以用来解决后者。
很显然,约化具有一项重要的性质:约化具有传递性。如果问题A可约化为问题B,问题B可约化为问题C,则问题A一定可约化为问题C。这个道理非常简单,就不必阐述了。
现在再来说一下约化的标准概念就不难理解了:如果能找到这样一个变化法则,对任意一个程序A的输入,都能按这个法则变换成程序B的输入,使两程序的输出相同,那么我们说,问题A可约化为问题B。
当然,我们所说的“可约化”是指的可“多项式地”约化(Polynomial-time Reducible),即变换输入的方法是能在多项式的时间里完成的。约化的过程只有用多项式的时间完成才有意义。
好了,从约化的定义中我们看到,一个问题约化为另一个问题,时间复杂度增加了,问题的应用范围也增大了。通过对某些问题的不断约化,我们能够不断寻找复杂度更高,但应用范围更广的算法来代替复杂度虽然低,但只能用于很小的一类问题的算法。再回想前面讲的P和NP问题,联想起约化的传递性,自然地,我们会想问,如果不断地约化上去,不断找到能“通吃”若干小NP问题的一个稍复杂的大NP问题,那么最后是否有可能找到一个时间复杂度最高,并且能“通吃”所有的 NP问题的这样一个超级NP问题?答案居然是肯定的。也就是说,存在这样一个NP问题,所有的NP问题都可以约化成它。换句话说,只要解决了这个问题,那么所有的NP问题都解决了。这种问题的存在难以置信,并且更加不可思议的是,这种问题不只一个,它有很多个,它是一类问题。这一类问题就是传说中的NPC 问题,也就是NP-完全问题。NPC问题的出现使整个NP问题的研究得到了飞跃式的发展。我们有理由相信,NPC问题是最复杂的问题。再次回到全文开头,我们可以看到,人们想表达一个问题不存在多项式的高效算法时应该说它“属于NPC问题”。此时,我的目的终于达到了,我已经把NP问题和NPC问题区别开了。到此为止,本文已经写了近5000字了,我佩服你还能看到这里来,同时也佩服一下自己能写到这里来。
NPC问题的定义非常简单。同时满足下面两个条件的问题就是NPC问题。首先,它得是一个NP问题;然后,所有的NP问题都可以约化到它。证明一个问题是 NPC问题也很简单。先证明它至少是一个NP问题,再证明其中一个已知的NPC问题能约化到它(由约化的传递性,则NPC问题定义的第二条也得以满足;至于第一个NPC问题是怎么来的,下文将介绍),这样就可以说它是NPC问题了。
既然所有的NP问题都能约化成NPC问题,那么只要任意一个NPC问题找到了一个多项式的算法,那么所有的NP问题都能用这个算法解决了,NP也就等于P 了。因此,给NPC找一个多项式算法太不可思议了。因此,前文才说,“正是NPC问题的存在,使人们相信P≠NP”。我们可以就此直观地理解,NPC问题目前没有多项式的有效算法,只能用指数级甚至阶乘级复杂度的搜索。
顺便讲一下NP-Hard问题。NP-Hard问题是这样一种问题,它满足NPC问题定义的第二条但不一定要满足第一条(就是说,NP-Hard问题要比 NPC问题的范围广)。NP-Hard问题同样难以找到多项式的算法,但它不列入我们的研究范围,因为它不一定是NP问题。即使NPC问题发现了多项式级的算法,NP-Hard问题有可能仍然无法得到多项式级的算法。事实上,由于NP-Hard放宽了限定条件,它将有可能比所有的NPC问题的时间复杂度更高从而更难以解决。
不要以为NPC问题是一纸空谈。NPC问题是存在的。确实有这么一个非常具体的问题属于NPC问题。下文即将介绍它。
下文即将介绍逻辑电路问题。这是第一个NPC问题。其它的NPC问题都是由这个问题约化而来的。因此,逻辑电路问题是NPC类问题的“鼻祖”。
逻辑电路问题是指的这样一个问题:给定一个逻辑电路,问是否存在一种输入使输出为True。
什么叫做逻辑电路呢?一个逻辑电路由若干个输入,一个输出,若干“逻辑门”和密密麻麻的线组成。看下面一例,不需要解释你马上就明白了。
┌───┐
│ 输入1├─→┐ ┌──┐
└───┘ └─→┤ │
│ or ├→─┐
┌───┐ ┌─→┤ │ │ ┌──┐
│ 输入2├─→┤ └──┘ └─→┤ │
└───┘ │ ┌─→┤AND ├──→输出
└────────┘┌→┤ │
┌───┐ ┌──┐ │ └──┘
│ 输入3├─→┤ NOT├─→────┘
└───┘ └──┘
这是个较简单的逻辑电路,当输入1、输入2、输入3分别为True、True、False或False、True、False时,输出为True。
有输出无论如何都不可能为True的逻辑电路吗?有。下面就是一个简单的例子。
┌───┐
│输入1 ├→─┐ ┌──┐
└───┘ └─→┤ │
│AND ├─→┐
┌─→┤ │ │
│ └──┘ │ ┌──┐
│ └→┤ │
┌───┐ │ │AND ├─→输出
│输入2 ├→─┤ ┌──┐ ┌→┤ │
└───┘ └→┤NOT ├→──┘ └──┘
└──┘
上面这个逻辑电路中,无论输入是什么,输出都是False。我们就说,这个逻辑电路不存在使输出为True的一组输入。
回到上文,给定一个逻辑电路,问是否存在一种输入使输出为True,这即逻辑电路问题。
逻辑电路问题属于NPC问题。这是有严格证明的。它显然属于NP问题,并且可以直接证明所有的NP问题都可以约化到它(不要以为NP问题有无穷多个将给证明造成不可逾越的困难)。证明过程相当复杂,其大概意思是说任意一个NP问题的输入和输出都可以转换成逻辑电路的输入和输出(想想计算机内部也不过是一些 0和1的运算),因此对于一个NP问题来说,问题转化为了求出满足结果为True的一个输入(即一个可行解)。
有了第一个NPC问题后,一大堆NPC问题就出现了,因为再证明一个新的NPC问题只需要将一个已知的NPC问题约化到它就行了。后来,Hamilton 回路成了NPC问题,TSP问题也成了NPC问题。现在被证明是NPC问题的有很多,任何一个找到了多项式算法的话所有的NP问题都可以完美解决了。因此说,正是因为NPC问题的存在,P=NP变得难以置信。P=NP问题还有许多有趣的东西,有待大家自己进一步的挖掘。攀登这个信息学的巅峰是我们这一代的终极目标。现在我们需要做的,至少是不要把概念弄混淆了。
Matrix67原创
转载请注明出处
