如果愿意和我讨论问题请在此留言。
本blog只讨论技术问题,谢谢!
如果愿意和我讨论问题请在此留言。
本blog只讨论技术问题,谢谢!
今天终于将拖延多时的geometric points-to analysis package向Soot Maintainer提交了。关于geometric points-to analysis的算法细节,愿意的读者请参见我主页(http://www.cse.ust.hk/~richardxx/)的ISSTA 2011 paper 。source code我也会很快放出,所以感兴趣的读者不用等Soot的下一个版本放出即可先睹为快。
凭良心说,我的这个算法还是不错的,并不是发篇paper忽悠人。至少速度很快,而且配置简单。John Whelay 的bddbddb虽然也很优秀,但是无法容易的和soot(基本上算是Java程序分析最常用的软件了)配合使用是一个硬伤。而Paddle,虽然write for Soot,但是配置极其复杂。而且,算法实现的不太好,运行速度太慢。
Points-to analysis虽然研究了几十年,但是有效的做context sensitive analysis仍然有很多路走。在这点上Java又比C难,因为Java程序常常有个skinny的call graph,导致contexts的数量非常大,很多我测试的软件都超过了2^63个contexts。因此,有兴趣的researcher希望继续做相关研究。当然,希望你能用我的代码做baseline,:>
我不知道我这个blog的读者中有没有做PL的。有的话,欢迎和我讨论points-to analysis相关的话题~~
一年多没有更新blog了。一则是去年yo2遭遇服务器瘫痪,大半年无法连接。二是当服务恢复后,我发现密码弄丢了。申请从邮箱重置密码,结果我的gmail一直没收到重置的邮件,所以作罢。
今天终于搞定密码,遂打算继续打理这个blog。看着以前的日志,感慨良多啊,觉得自己以前真的是很勤劳。最新的几条网友评论我都无法回复,因为我实在记不清那些算法的细节了, 所以很抱歉。
Anyway,从今天开始继续我的学习、研究的笔记。当然,ACM相关的东西我是无能力再更新了,以后就主要以programming language的研究和心情日志为主。
Software Engineering PhD or RA position at HKUST
I am looking for a candidate for the position of either the research assistant or the PhD graduate student in the area of software engineering or programming languages. The student must be strong in either algorithms or math and passionate about programming. The candidate must also be self-motivating, curious, and independent.
The salary of RA is around 9000 HKD per month, and graduate student will be awarded a full scholarship at around 13K per month.
Interested students are welcomed to visit my website: www.cse.ust.hk/~charlesz for more details of the research projects and to send his/her resumes and other supporting documents to [email protected]
有趣的是,我还是没能遵守诺言,再忙也抽点空来写写blog。一晃四个月过去了,也学习了一些有趣的东西,又到了该总结一番的时候。
遗憾的是,本来想做一个纯粹的软工research,最终没能实现。这其中有值得总结的地方,首当其冲的问题便是我先入为主的认为一个问题它的复杂度高,所以我只要能把降下来那就可以发paper。其次,因为我的懒惰,所以惧怕去做一些不太兴趣的事情。所以,当我好不容易把一个很实际的问题提炼成图论模型时,我自然不想松手。
事实证明,其实naïve的方法,才是最好的方法:又好理解又好写。根据Occam剃刀原理,其它方法应该被排除了。可是,刚开始,我并没意识到这一点,因为我没有料到一个非常普通的数据结构: sparse bitmap,在实际中性能表现那么好。我承认我起初并非不知道这个结构,而是出于写起来比较麻烦,而没有去做。后来发现GCC里面实现了,我才赶忙拿出来尝试。记得第一次实验完的时候,我心是完全凉了,知道它会好没想到它会这么好。我心里一横,想反正没做多久,干脆抛弃了赶紧找新topic,离deadline还有一个多月,或许还来得及。
就这样,我悄无声息的开始尝试弄一些新的东西出来。后来我有了些小进展,跟老板说,他也觉得奇怪,怎么我突然就不做以前那个了?我没敢说我的实验,因为当初就是因为老板对我的信任,才让我尝试的,我不能告诉他失败了,我只能说contribution不够大,希望做一些results加强,这样更有希望。
对了,还没介绍我一直在做什么。pointer analysis,一个已经研究了四十年的东西,就是静态分析指针该指向何处,任何一个现代的编译器都会分析,只是算法不同分析结果的精度不同,因为广义上它是个undecidable问题。我选它并非是想challenge the whole world,我自知没这个能力,原因仅仅是这个问题很容易理解。参加过今年TIC决赛的同学估计还记得那道A题,便是exactly我所一直在研究的一个问题,然后出了题去恶心大牛的。虽然9月的ICSE我最终没能做出一些东西,但我肯定还会继续探索,因为我在PL差得还太多太多,做一个researcher不能操之过急,当然更不能临阵退缩。
不过,虽然高水平的研究是一个漫长的过程,但HKUST并不会因此而同情你,你得发paper,失败不得。ICSE过后,我又把我那个已经丢在垃圾桶中的算法捡起来,重新改了改并增加了一些证明,弄得还算fancy,投了出去。没报希望,但也必须尝试。
下一步,该往何处?自然,这有段空隙,是该补补基础的时候了。打算花一个月时间,把compiler中的一些基本问题再弄明白些,比如data flow analysis的lattice theory以及其应用。不能以后出去别人问起这些基本问题我心里还打鼓,讲得个是是而非的,那说我是做PL的不是个千古笑话么?
下一篇blog打算从compiler前端开始复习,第一个topic是DFA minimization和predict parser。看能不能把Hopcroft的O(nlgn)算法讲清楚。
相隔近两月才来写这篇报告,估计大家早没有兴趣听这个题目了。最近一直在赶paper的档期,实在是静不下来写一些东西,恐怕这样的状态还要持续好一阵。
先回顾一下题目。一个图,每个点x有两个值f(x)和g(x)。两点x和y之间的距离定义为:d = ( f(y)-f(x) )/( g(y)-g(x) )。现在要往图里面添加边,规则如下:
1. 从所有异于x的点中选取一个顶点y,使得y到x的距离d是所有点中最小的,并且d >≥0。如果有多种选择,则选abs(f(y)-f(x))最小的一个(abs(t)表示取t的绝对值),如果有多个abs(f(y)-f(x))相等,则选择f(y)-f(x)>0的那个点;
2. 添加无向边(x, y)(如果这条边已经存在,则不重复添加)。
现在要回答问题,每个问题询问x和y是否存在一条path。
大家都能很快看出,此题的后半部分就是一个并查集,难处主要在前半部分:这个图中哪两点间有边?因为题目的规模是有50,000个顶点,所以一个直接的O(n^2)算法是不行的。
其实,该题的障眼法我觉得设计得还是不够好:一看那个求d的公式,大家就基本上都猜到这个问题肯定和斜率有关。再加上题目的tips中说每个点的f和g值都不相同,于是基本上就可以确定这个题是一个计算几何了。
我坦白,这个问题其实就是要在平面n个点找形成直线后斜率最大的点对。首先把求d的那个公式倒过来一下,于是就变成了求1/d最大的问题。我们设每个点x的坐标为( f(x), g(x) ),很自然的把这些点表示出来。
然后就是把它们按照X坐标从小到大排序了。因为我们要求的是d ≥ 0(自然1/d ≥ 0)的直线,自然对每个点,只需要考虑如果以它为原点建坐标系,那么在1,3象限中的那些点。由于1和3象限的对称性,我们只说明1象限怎么做。
我们按照X坐标从大到小的顺序访问这些点。现在我们考察点x。我们用T(x)表示那些在X坐标比x大的那些点。如果要从T(x)找一个点y出来和x连线得到的斜率最大,那y肯定要是T(x)形成的凸包的上半部分。这个证明比较容易,因为假设y在凸包内部,那么我们知道x和y的连线一定和凸包上的某条边E相交了。设E的两个顶点是e1和e2,其中e1的Y坐标大于e2的Y坐标。那么我们可以知道,选择e1和x连线一定不会比选择y连线得到的直线斜率小。于是得到y应该从凸包上取。
有了这个分析,下一步就是如何从凸包上去找到y。很巧的是,这里面有个单调性,利用它可以设计巧妙的算法,见下图:

其中e1 – e5是凸包上的点,然后L1 – L5是x到这些点的连线。可能一个很直观的感受是,只要选凸包上Y值最大的点就行,于是上面的图就可以选出e3来。但其实不然,比如下图:

这就像一个人站在X处打探,e1和e2是两座山顶,那他肯定无法看到e2(即X – e1的斜率大于X – e2的斜率)。从数学的角度来描述这个事实非常容易:线段e2 – e1和e1 – X的叉积大于等于0。
于是,我们可以通过枚举凸包上连续的两点e1和e2,然后做一个叉积:如果大于0,我们知道e2和e2右边的所有点都被e1挡住了(因为上凸包的左转性质很容易证明)。于是这个单调性就很明显了:
如果e1挡住了其后面的点的“视线”,那么可知要找的最优点y一定是e1或者e1左边的点。依次,我们可以设计一个简单的二分算法来枚举凸包上的点,这样就可以使得求最优点的每步操作提高到lgn。
当然,计算几何题最烦人的地方就在于细节很多。以上给出了主要的思路,剩下的细节就大家自己想想。其实现在来看,C题明显比B的思路要简单,而且编程也不会更复杂。我本来也以为B应该是整套题最难的。不过从当时比赛的结果来看,大家似乎都只对B感兴趣,而忽略了C,我想主要还是因为B看起来要熟悉一些,而在比赛中一般都比较喜欢偷懒,有做过的题自然就要先照顾了。
夜深,剩下的一篇花絮希望尽快补上,不要再拖两个月-_-~~。
非常荣幸今年能够有机会为这样一个大规模的程序设计竞赛出题,在全国为数众多的OIers和ACMers面前献丑,也实属鼓足了勇气,万幸的是整个比赛比较平静,看各个论坛和群也没有人指责我题目的问题,算是得到了些许安慰。
我计划本篇报告一共分成三次来写,第一和二部分分别是B和C的详细算法,第三部分是我关于这次题目在出题过程中的一个简单总结,或者叫花絮,希望愿意读的朋友来捧场。
好了,先来看看题目。B题的原文描述如下:
现在,麻烦来了,T公司要在全国n(1<=n<=10,000)个城市中架设服务器,以提高其网络服务质量。但由于资金限制,我们只能选择其中P(1<=p<=50且p<=n)个城市作为服务中心,其余的城市需要由这P个城市中的一个来提供服务。如何选择这P个城市并提高通讯质量难倒了一批又一批的工程师。
好在,传闻Richard是这方面的专家,于是T特聘他来帮忙解决问题。果然,Richard到了公司后就开始大展拳脚。首先,对于城市A,如何快速的求出应该把哪个城市的服务器分配给它?这个问题至关重要,因为用户并不希望等待过长时间才开始使用服务。
你的方法是通过考虑每个城市服务器的特点,然后将每个城市表示为一个整数(32位有符号整数范围)。对于城市A,我们表示为network(A)。那么,谁来为A提供服务?令对任意城市x,value(A)=abs(network(x)-network(A))(其中abs为取绝对值),那么,我们说为A提供服务的城市应该是使得value(A)最小的一个(如果有多个,则任意选一个)。这一步处理工作完毕后,剩下的工作是如何找到这P个安放服务器的城市。为了达到最好的服务质量,这样的安排需要满足所有城市的value总和最小。
这个问题难倒了Richard。为了拿到工钱(更重要的是保住其专家的称号),他决定不惜重金悬赏求解这个问题。聪明的你,还不快来试试?
其实这就是IOI2000经典问题Post Office的升级版。很多人可能都看过毛子青的那篇经典的将动态规划优化的论文,但惟独一个遗憾是他那篇文章在分析用四边形不等式优化该问题时最后得到的复杂度结论有错,那个算法应该是O(n^2)的复杂度而非O(np),这也是为什么很多人吃了TLE的原因。
其实,更早有一篇IOI集训队论文就已经讲到了一个非常优美的算法,不过那篇文章非常简短,所以很多人可能没有在意。我标程的算法其实就是那个,在这篇报告中我稍微详细的讲讲。
首先对所有数据进行排序,这步证明很基本,反证得之,略过。然后列出基本的DP方程:f(k, j) = f(k-1, i-1) + W(i, j)。其中,f(k,j)表示从前j个城市中选择k个作为服务中心的最小开销,W(i,j)表示从数据排行第i的城市至第j的城市中选取一个作为服务中心的最小代价。假设排序后的数据存放在X中。
下面首先证明一个性质:使得W(i,j)最小的城市一定是第(i+j)/2个。假设P(k)表示选取第k个城市作为中心,如果k < (i+j)/2,那么有:
P(k+1) = P(k) + (k+1-i)*( X(k+1) – X(k) ) – (j-k)*( X(k+1) – X(k) )
= P(k) + (k*2+1-i-j)*( X(k+1) – X(k) )
因为 k < (i+j)/2,所以有k ≤ (i+j)/2 – 1,就有(k*2+1-i-j) < 0。因此得到P(k+1) < P(k)。同理,当k > (i+j)/2时也可以如法炮制的证明。于是原命题得证。
接着我们来回顾一下所谓的Convex Monge Condition,也就是常说的四边形不等式。仍然用上面的W来描述,就是:
W(a,c) + W(b,d) ≤ W(b,c) + W(a,d),对所有的a < b 且 c < d成立。
接着介绍的另外一个很类似的概念:Convex Totally Monotone,中文叫凸完全单调性。定义如下:
W(a,c) ≥ W(b,c) => W(a,d) ≥ W(b,d),对所有的a < b且c < d都成立,其中=>是逻辑蕴含的意思。
这个东西有什么神奇的性质呢?来看两个:
1. 假设W是个n行m列的矩阵(以后都这么说),设Ri表示第i列的所有元素的最小值所在的行。那么有R1<=R2<=R3…<=Rm;
2. 四边形不等式能推导出凸完全单调性,但是反之则不然。
我现在要说是,这个题目中的W满足这个凸完全单调性吗?或者更甚,满足四边形不等式吗?这两个问题的答案都是肯定的。那么如何证明呢?因为后一个命题的更强,所以我们只需要证明满足四边形不等式就好,可以通过两步证明:
1. 对所有的i ≤ p ≤ j,有W(i, j+1)-W(i, j) ≥ W(p, j+1)-W(p, j)。
2. 将上式中的i和p替换为a和b,然后将j替换为所有区间[c,d]间的数,于是得到d-c+1个不等式,把它们加起来就可以得到答案。
下面就利用这个性质来设计高效算法。
回顾刚才的基本方程:f(k, j) = f(k-1, i-1) + W(i, j)。当k固定的时候,我们需要枚举i和j,在naïve算法中这是个O(n^2)的过程。假设矩阵B(i, j) = f(k-1, i-1) + W(i, j),其中k ≤ i ≤ j,那f(k, j)其实就是B的第j列的最小元。因此,我们的目标就是加快求出B的所有列的最小元。
首先可以证明,当W满足四边形不等式时,B也满足。自然,B也同时满足凸完全单调性。我们用一个三元组(start, end, r)表示在[start, end]间的所有列的最小元在第r行上。算法开始时,我们只有一个元组(k, n, k),即当我们只考虑了第k行的元素时,所有列的最小元都在第k行。接下来我们一次考虑第k+1, k+2……行。设当前考虑第p行,那么我们的工作就是在最快的时间内找到一个t,当所关心的列 ≥ t时,我们有他们第最小元在第p行。
首先,我们用一个队列维持当前的所有元组,并且满足下一个元组的start是上一个的end+1,即列的区间维持一个升序。然后初始化t = n+1,然后从最后一个元组开始考虑,那么只有三种情况:
1. 当B(end, p) ≥ B(end, r)时,我们知道对所有的c < end,有B(c, p) ≥ B(c, r)。这个就是凸单调性的逆否命题。这时我们不需要再向前扫描了;
2. 当B(start, r) ≥ B(start, p)时,根据凸单调性,对所有的c > start,有B(c, r) ≥ B(c, p)。这时我们知道该元组已经作废了,因为[star, end]中的所有列的最小元都不在r,至少有p就比它们小。于是弹出改元组,继续向前扫描;
3. 第三种情况就是,在[start, end]中存在一个t,使得对所有c ≥ t,有B(c, r) ≥ B(c, p)。如何来找这个t呢?通过二分查找就可以轻松的确定。这时也不需要再继续向前扫描。
然后我们就得到了这么一个t。如果t < n + 1的话,我们就加入一个元组( t, n, p),否则就什么都不干。当最后一行n都计算完毕后,此时我们在队列中就维护者我们之前想要计算的答案:对于B的每一列,它的最小元在哪一行。
通过均摊分析,我们发现整个计算过程是O(nlgn)的复杂度。当然,B一共需要求P次,所以总的复杂度是O(Pnlgn)。
具体实现还是有好多需要考虑的细节,大家自由发挥。如果愿意的话,可以向我提出挑战我的程序(如果你有更好的算法,希望你能告诉我,因为我知道传说中好像真的有一个O(nP)的算法存在,只是我能力有限未能搞明白),然后咱们一边去晒晒,^_^。
这篇就到此为止,本题的数据和参考代码可以跟我发邮件([email protected])索取。下一篇讲C 题。说实话,其实我更喜欢C一些,因为solution所需要的知识非常简单,只是中间的模型变化不容易想到,而B是如果没学过相关知识,从头到尾都不容易想。貌似比赛中C没有被人做掉,这点我还是有一些遗憾,希望大家能再想想。
很久没有来写blog了,眼看着有些荒废的迹象。自上篇文章以来,主要是经历了过年的颓废期,玩得有些忘乎所以,所以工作和研究上近乎停滞。
二月份没干几件正事,就想着到处去玩了。殊不知,玩这种运动是很容易上瘾的,于是荒废了大半个月。如果算上一月份的话,整两个月就仅仅把A. Aho的那本自动机和形式语言的前6章温习了,做了些习题。虽然至今又几乎全部还给了课本,但总算是圆了个心愿。
三月份重新拾起以前落下的研究计划。之前由于对Bug Pattern Matching没有什么好的思路,于是才放下去读书。我觉得我没有思路的根本原因不是缺乏研究的能力,而是从没有一头扎入代码中去看足够多的实例,而完成这项工作最好的便是能做一个静态代码的分析工作,于是我便能从代码中很从容的抽出所要的feature。而当我再次拿起这个课题时,我深感必须花时间去做这么个工具,或者是暂时放下,寻求另外一个问题。
那么,做什么好呢? 我不知哪来的灵感,想到了parallel programming中的data race问题。说实话,我只是在之前的一个个人软件中遇到了这个问题,而且还是最容易解决的那种多线程竞争(我当时没用锁,呵呵,我不太懂啊)。其它时候我就根本没有碰过这个东西。现在要做,完全是来源于某个通宵的结果:偶然的在对着书柜发呆时,突然看到了那本已经落灰的Butenhof经典著作Programming with Posix Threads,然后故作久别重逢状的拾起翻阅了目录。其中有一章title,讲如何避免bug的那章吸引了我,见页数不多,便认真读毕。突然一种豁然开朗的态势,觉得data race是一种量化特征非常明显的bug,如果用control flow graph来表达程序,那其上的数据流方程可以很容易的detect常见的data race错误。
于是,我研究了一个晚上,总算有了一些结果。我不愿意知道是否人们已经做烂了这个题目,因为我只想训练自己的研究问题的能力,所以没有去读任何paper,所有的资料便是那本书。可以想象,大脑已经闲置了近两个月的我,此时看到这么一个可以做的题目,那是何等的激动啊。
第二天匆忙写好draft report,免得才想好的东西马上就消失了。接下来几天便是继续refine和elaborate的过程。这些天不好过,毕竟一晚上的东西有很多结论都是粗糙的。整个过程前后持续了13天,中间细节就不数了,最后完成了一篇长达19页的paper,看得自己发晕,看得老师也发晕。
当然,现在来看,这篇paper中真正有价值的东西不太多,因为是独立研究的结果,所以很多东西其实已经做得很成熟了。当然,还是庆幸自己有那么一次经历,至少开阔了自己的视野。
啰嗦了这么多, 那就列一下后续的一些写作计划吧。前一段时间由于完成一个小项目的原因,前后有进一个月时间认真分析了GCC的部分源代码,并做了部分改动。我打算写一些分析的总结,也能让自己日后有个参考。
另外就是一些读了些有趣的paper。现在开始走学术路线了,就得多做一些paper的总结。其实现在发现,原来读paper并不是一件很郁闷的事情,特别是那种研究内容很有新意的东西,往往还能给人醍醐灌顶的感觉,让你知道原来这个研究要这么开展。
我不知道大家是不是和我一样有这样的认识:一级指针和一维数组的语义行为是等价的,或者从编译器的角度看,两者是一样的。我想很少有人清楚编译器如何工作,又或许在实际工作中把两者当成同一物没有出现过问题,也就不会去细究两者的异同。
语义行为就是编译器用一系列操作对某个产生式(production rule)的解释。对于一个指针int *p,它的语义行为有:
另一方面,int p[]有两层含义:
如果表达式为int p[10],它相对int p[]要做越界检查。对于高维数组,其语义和一维的完全一样,只是地址计算上稍微繁琐。
一种常见的混用方式是:
这段程序不会有问题,因为参数传递是按值复制的,就是说p的值为v的值(就是v数组的地址)。但下面一段程序就要出错了:
A.c
B.c:
这段代码错误的原因在于:v作为数组时,其v的值语义为v的地址。现在v被告知是一个指针,于是v的值就是v的地址中所包含的值,上例中就是1。v[2]相当于1[2],即取地址为3中的值,自然会segmentation fault。
看下面的示例代码:
void f( int **v )
{
printf( “%dn”, v[0][1] );
}
void g( int v[2][3] )
{
printf( “%dn“, v[0][2] );
}
int main()
{
int v[][3] = { {1,2,3}, {3,4,5} };
int **t = v;
f( v );
g( t );
return 0;
}
很多人可能认为(包括我),二维数组的变量被编译器当做二级指针看待。其实这是错的,上例中f(v)的调用将会导致一个segmentation fault。根据数组的语义,int p[2][3]实际上是p指向一个int[2][3]类型的变量,据此来看看在f中发生的事情:v[0][1]实际上在尝试解析内存地址2,因为参数v的值就是传入数组的起始地址,所以v[0]就是1,然后解析地址1[1],固然导致错误。
那么,如何改造f,可以得到正确的输出呢?f可以如下实现:
void f( int **v )
{
int (*t)[3] = v;
printf( “%dn“, *((t+1)[0]) );
}
但如果f在一个已经发布的函数库中(如getopt的第二个参数),那么我们便不能改动f。怎么办呢?此时就需要在调用f前做一件事情:
int **p;
p = (int**)malloc( sizeof(int) * 2 );
for ( i = 0; i < 2; ++i )
p[i] = (int*)malloc( sizeof(int) * 3 );
for ( i = 0; i < 2; ++i )
for ( j = 0; j < 3; ++j )
p[i][j] = v[i][j];
f( p );
为什么这样?p与v不同就在于数据没有连续存放,中间引入了一个转换层。因此,p的地址解析需要通过两步完成,而v只用一步。除此,p还比v多浪费了2个void*的空间用于存放中间转换层数据。
上文中g(t)的调用是对的这个很容易看出,因为t只是起了个保存v的数据的作用,并没有做解析,然后将其传递给了类型匹配的参数。那用到t转换一次的意义在哪里呢?如果将g改造如下:
void g( int v[3][2])
{
printf( “%dn“, v[0][2] );
}
此时v的类型为int[3][2]而并非起初定义的int[2][3],程序仍然能编译通过。原因就在于用t做转储时,丢失了范围语义信息,所以再转换回来时编译器已经糊涂了,也只好默认它们能正常工作,只是释放一个warning而已。
以上可见,从语义的角度来学习一个语言,会在理解上有本质的提高。对于遇到的“奇怪”问题,多做些这样的分析才可以得到正确的解释。
先离题,我觉得有必要先定义难和易的概念。
有些题目,它可能要经过好几步的模型转换,而且每一步的处理都需要一些比较高深的算法,比如网络流,自动机等。但是,其中的每一步的转化非常的straight forward,或者说用一些很普通的处理手段就可以看出解决方法。这类的题目非常多,特别是POJ月赛中。但我认为它们并不能算真正意义上的难题,能够做出不值得骄傲。
还有些问题,可能解决方法非常简单,代码量也非常小,但是却需要打破常规思路才能得到答案。我觉得就是要做这类题目,才可能在思维上有质的飞跃。
最近的两场SRM(423和424)就出现了两道我觉得值得一提的题目,下面介绍一下。
第一道题目是300pts。一个无限大的棋盘上放了n (n<=50) 个棋子,初始是任意的放置,所以也可能有位置相同的。问题是,最少移动多少步可以使得至少K个棋子在同一个位置上?其中从A移动到B的步数是两者的曼哈顿距离。
有一个很简单的问题我们或许都知道其答案:将n个棋子放在一起最少需要多少步移动?由于曼哈顿距离的特点,我们可以分开算X坐标和Y坐标。最后所有棋子移动到的点的X坐标一定是它们初始坐标排序后的中位数,Y坐标同理。因此,我们可以在O(n)的时间内解决将所有棋子移动到一起的问题。
现在考虑K<n。显然,普通的做法就是在n个中枚举K个棋子,然后用上面的方法计算移动步数。这样做的复杂度是O(C(n,k)*n),显然不能接受。那么,如何优化?其实很简单,就是将这个不可接受的算法的计算顺序颠倒一下。仔细想想,由于X和Y坐标都是取中位数,也就是说最终要移动到的位置的X和Y的坐标是可以从已知的这n个点中组合出来的!因此,n个点最多有n^2个候选的位置,而对于选出的目标位置,很显然就是是n个点中离它最近的K个移动到一起。因此,通过简单的调换计算顺序避免了大量重复计算,将复杂度降为O(n^2KlgK)。
其实,这个算法也体现了动态规划思想的精髓:避免重复计算。然而,上面看似简单的算法,却非常不容易想到,原因就是我们先入为主的思想在作祟。
另外一个是600pts的题目。你在玩一个冒险游戏,现在有n个关卡。要通过一个关卡,玩家的当前智商和力量的值至少有一个要大于等于该关卡的限制。每通过一关,可以得到一些分数,你可以将这些分数任意的分配到你的智商和力量的属性上。初始你的智商和力量都为1,请问,如果通关的顺序没有要求,请问你最多能通过多少关?其中,关卡的属性值限制<=1000,n<=50。
初看真没什么想法,那就枚举吧。先枚举出通关的顺序,然后再求解这个顺序下最多能通多少关,复杂度是O(n!*n)。想基于这个思路动规,好啊,那总得状态压缩一下吧,答案是O(1000*1000*n*2^n),没觉得这个方法有多好,怎么办?
其实,第一题的优化已经提示我们,盲目的枚举必然隐藏了大量的重复计算。回顾一下,第一题的重复计算在哪里?其实就在于,虽然一共有C(n,K)种点的组合,但是它们却一共只有O(n^2)中不同的最终集合位置。形式化一点,就是这指数阶的组合方案它们的某一个性质一共只有多项式阶种。回到这道题,其实就是所有通关顺序得到的最后的智商和力量属性的组合最多只有1000^2种(去掉>1000的情况,因为1000已经是所有关卡要求的上限)!
那么依葫芦画瓢,先枚举一种属性组合,然后再求出能得到这个属性组合的最优通关序列。很快发现,这样的话,不直接枚举(1000,1000)就可以通过所有关卡了?所以,再这一思路上,我们还应该多做些限制才好。
那么,为什么不能枚举(1000,1000)呢?原因就是这个组合或许根本就不存在。而题目一中不做这个处理的原因是,虽然有些位置不可能作为最终位置,但是计算它们不会影响最后答案。那如何快速判断哪些状态是可能出现的呢?
设状态(x,y)是可达的,则(x,y)必然是由某一个可达状态(x’,y’)通关后得到P分生成的(注意,假设P可以不用全部分配。因为这一假设不影响最后的答案,所以它是可行假设)。因此,很容易想到可以通过广度优先的过程从(1,1)开始,每次检测该状态能通过的所有关卡能得到的总分数,然后把剩余分数,即left=y+x-2分配后求得其它的可行状态。
答案很明显了,不再多说。
再总结一下这两道题目的特点。它们将一个具有指数阶以上定义域的问题转化到只有多项式阶的状态空间中,再在此状态空间中设计算法以得到原问题的最优解。我们可以看到,上面的算法的都不难证明,而且代码也容易写就,难就难在如何转化问题的状态空间。我觉得,只有多做这样的题目,才能迅速成长。
最后,即使你实现上面的算法很容易,也请抽空看看楼教主的代码,非常漂亮。