转自:Matrix67的博客
0.我为什么要写这个?
信息学竞赛与其它学科的竞赛相比有其特殊性:教师在里面起的作用不大,主要靠自己通过各种渠道获取信息.我每天都会收到很多OIer发来的消息,他们迫切希望知道很多OI知识.但是,资源是有限的,即使在网络中也是.过于专业化的东西在中文网络上搜索起来并非易事.并且,OIer们所找到的东西并不一定完全可靠.不少人学习OI都是抱着一两本奥赛书或者在OIBH中搜索,但殊不知这些地方的很多东西也都不一定完全正确.两年前,我也是一个什么都不知道的 OIer.我也曾经在书店、在网络上苦苦地搜索过.因此,我知道现在OIer需要什么.我知道哪些东西OIer找不到,哪些东西普遍存在误解.我所写的东西都是我能想到的网上不太容易找到或者存在误区的问题.我想到需要写什么我就写什么,这些内容没有顺序.
现在的OI资源存在一个问题:太过于数学化、符号化.在我看来,OI的这些问题不应该是数学化的东西,不应该用数学语言去描述.OI考的是创新能力,考的是形象思维.因此,我写的这些东西最大的特点在于形象化.我决不会扔下一大堆数学式子,而是着重表达出我的形象化理解.我竭力把一个问题说清楚,让即使没有学过OI,甚至没有学过数学的人也能看懂.
我的OI生涯算是基本结束了,但OI事业并未结束.我要做的事还有很多.我打算在这一年的时间里留下更多的资料分享给今后的OIer.我不会去想这些东西被编印成册,我只是想让更多的人能从中学到东西.OI应该在互联网中生存,而互联网的基本精神是共享.在此,我只有一个要求,这些东西转载时请注明出处.
1.澄清P问题、NP问题、NPC问题的概念
这或许是众多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问题还有许多有趣的东西,有待大家自己进一步的挖掘.攀登这个信息学的巅峰是我们这一代的终极目标.现在我们需要做的,至少是不要把概念弄混淆了.
2.什么是离散化?
如果说今年这时候OIBH问得最多的问题是二分图,那么去年这时候问得最多的算是离散化了.对于“什么是离散化”,搜索帖子你会发现有各种说法,比如“排序后处理”、“对坐标的近似处理”等等.哪个是对的呢?哪个都对.关键在于,这需要一些例子和不少的讲解才能完全解释清楚.
离散化是程序设计中一个非常常用的技巧,它可以有效的降低时间复杂度.其基本思想就是在众多可能的情况中“只考虑我需要用的值”.下面我将用三个例子说明,如何运用离散化改进一个低效的,甚至根本不可能实现的算法.
《算法艺术与信息学竞赛》中的计算几何部分,黄亮举了一个经典的例子,我认为很适合用来介绍离散化思想.这个问题是UVA10173(http://acm.uva.es/p/v101/10173.html),题目意思很简单,给定平面上n个点的坐标,求能够覆盖所有这些点的最小矩形面积.这个问题难就难在,这个矩形可以倾斜放置(边不必平行于坐标轴).

这里的倾斜放置很不好处理,因为我们不知道这个矩形最终会倾斜多少度.假设我们知道这个矩形的倾角是α,那么答案就很简单了:矩形面积最小时四条边一定都挨着某个点.也就是说,四条边的斜率已经都知道了的话,只需要让这些边从外面不断逼近这个点集直到碰到了某个点.你不必知道这个具体应该怎么实现,只需要理解这可以通过某种方法计算出来,毕竟我们的重点在下面的过程.
我们的算法很显然了:枚举矩形的倾角,对于每一个倾角,我们都能计算出最小的矩形面积,最后取一个最小值.
这个算法是否是正确的呢?我们不能说它是否正确,因为它根本不可能实现.矩形的倾角是一个实数,它有无数种可能,你永远不可能枚举每一种情况.我们说,矩形的倾角是一个“连续的”变量,它是我们无法枚举这个倾角的根本原因.我们需要一种方法,把这个“连续的”变量变成一个一个的值,变成一个“离散的”变量.这个过程也就是所谓的离散化.
我们可以证明,最小面积的矩形不但要求四条边上都有一个点,而且还要求至少一条边上有两个或两个以上的点.试想,如果每条边上都只有一个点,则我们总可以把这个矩形旋转一点使得这个矩形变“松”,从而有余地得到更小的矩形.于是我们发现,矩形的某条边的斜率必然与某两点的连线相同.如果我们计算出了所有过两点的直线的倾角,那么α的取值只有可能是这些倾角或它减去90度后的角(直线按“\”方向倾斜时)这么C(n,2)种.我们说,这个“倾角”已经被我们 “离散化”了.虽然这个算法仍然有优化的余地,但此时我们已经达到了本文开头所说的目的.
对于某些坐标虽然已经是整数(已经是离散的了)但范围极大的问题,我们也可以用离散化的思想缩小这个规模.最近搞模拟赛Vijos似乎火了一把,我就拿两道Vijos的题开刀.
VOJ1056(http://www.vijos.cn/Problem_Show.asp?id=1056) 永远是离散化的经典问题.大意是给定平面上的n个矩形(坐标为整数,矩形与矩形之间可能有重叠的部分),求其覆盖的总面积.平常的想法就是开一个与二维坐标规模相当的二维Boolean数组模拟矩形的“覆盖”(把矩形所在的位置填上True).可惜这个想法在这里有些问题,因为这个题目中坐标范围相当大(坐标范围为-10^8到10^8之间的整数).但我们发现,矩形的数量n<=100远远小于坐标范围.每个矩形会在横纵坐标上各“使用”两个值, 100个矩形的坐标也不过用了-10^8到10^8之间的200个值.也就是说,实际有用的值其实只有这么几个.这些值将作为新的坐标值重新划分整个平面,省去中间的若干坐标值没有影响.我们可以将坐标范围“离散化”到1到200之间的数,于是一个200*200的二维数组就足够了.实现方法正如本文开头所说的“排序后处理”.对横坐标(或纵坐标)进行一次排序并映射为1到2n的整数,同时记录新坐标的每两个相邻坐标之间在离散化前实际的距离是多少.这道题同样有优化的余地.
最后简单讲一下计算几何以外的一个运用实例(实质仍然是坐标的离散).才考的VOJ1238(http://www.vijos.cn/Problem_Show.asp?id=1238)中,标程开了一个与时间范围一样大的数组来储存时间段的位置.这种方法在空间上来看十分危险.一旦时间取值范围再大一点,盲目的空间开销将导致Memory Limit Exceeded.我们完全可以采用离散化避免这种情况.我们对所有给出的时间坐标进行一次排序,然后同样用时间段的开始点和结束点来计算每个时刻的游戏数,只是一次性加的经验值数将乘以排序后这两个相邻时间点的实际差.这样,一个1..n的数组就足够了.
离散化的应用相当广泛,以后你会看到还有很多其它的用途.
2007.04.05补充:
VOJ1056那个例子看来还是有人不明白.
我发一张示意图,注意左边的10*7的数组是如何等价地转化为右边两个4*4的数组的

3.KMP算法详解
我们这里说的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[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串的子串.
串匹配是一个很有研究价值的问题.事实上,我们还有后缀树,自动机等很多方法,这些算法都巧妙地运用了预处理,从而可以在线性的时间里解决字符串的匹配.我们以后来说.
4.
说明一下
这篇文章我在持续转载
个人强烈推荐去作者的博客看一看
另,二次转载也请注明出处--原作者的出处
一个文科生,做到这样很不容易了
应该说理科生做成这样也很不容易
写的真的很不错!
看了一半,看不下去了
我昨天看了一个你写的位运算的教程,终于知道什么叫神牛了……