Prolog不是Hentai

我就不黑谁了不战谁了,光自黑我大Prolog了。

前些天,我吃饱了没事干搜什么东西的时候,搜到了这篇blog 。貌似是一个小弟学了Prolog的课程,然后用Prolog写了算法的小程序。然后告诉大家,Prolog好棒!

呃,如果这么写的话,估计谁都不敢用Prolog了。我这里根据他这第一个例子,稍微讲讲到底怎么写Prolog,不会吓到小盆友。当然我这些小程序,在三总看来可能也是渣。。。

:- use_module(library(lists)).
%%%% Question 1
ws("你的字符串").                  % 把问题给的长字符串贴到这里

get_all( Lists ):-  
    ws(WS),                        % WS是题目给的字符串
    reverse(WS, RWS),              % RWS是WS的反转字符串
    findall( Sublist,              % 找出所有满足以下条件的子字符串,放入Lists中
        (segment( WS, Sublist ),   % Sublist是WS的子字符串,
        length(Sublist, L),        % Sublist的长度是L
        L < 15, L > 2,             % L长度小于15,但大于2(减少计算量)
        reverse(Sublist, Sublist), % Sublist的反转是Sublist
        segment( RWS, Sublist )),  % Sublist是RWS的子字符串
    Lists ).

run( String ):-  
    get_all( Lists ),              % 得到所有的对称的子字符串
    member( S, Lists ),            % S一个对称的子字符串
    \+ (                           % 对于所有以下条件的组合,必须至少有一条为假
         member( OS, Lists ),      % OS是不同于S的另一个字符串
         S \= OS,                  
         length( S, L1 ),          % 得到OS和S的长度
         length( OS, L2 ),
         L1 =< L2 ),               % OS的长度大于或等于S
    name( String, S ).             % 把S保存成可识别的字符串

他在findall里限制了Sublist的长度15,为什么呢?一定是被跑的时候爆缸了。为什么会爆缸呢?因为用segment/2搞一次,就是O(N^2),他居然在findall里面搞了两次,然后再加上findall/3本身,这就是O(N^5)?当然,眼尖的同学肯定看出来了,L<15哪怕有理由,L>2肯定是错了,难道"aa"就不算回文嘛? 使用Prolog另一大忌就是,你如果不是真心要所有的solution,千万别没事findall/3或者set/2,哪怕你家内存不要钱啊不要钱。不过这是后话,这里先铺垫一下。

那么不动干戈的情况下,能够稍微把这点弄得好看点呢?我们先看如何知道一个字符串是否是回文。这件事在Prolog里面好简单,这位小哥也用了reverse/2嘛。一个叫Hentai的list如果是回文,那么后面这句就是true嘛。

reverse(Hentai, Hentai)  

reverse/2在语言库是怎么实现的呢?所有的Prolog都一样吧,最标准的tail recursion的勾当。所以也就没什么可怕的。这里再废话一下。Prolog的好处是什么呢,库很多也都是Prolog写的,不管SWI这样开源的,还是Sicstus这样要帐的,你都可以去看看啊。看看Jan Wielemaker和Per Mildner老师们都是怎么写Prolog的,看也看会了。。。

好,有了这个,我们暂时还用segment/2神马的,就可以把get_all/1改造成这样。

get_all( Lists ):-  
    ws(WS),
    findall( Sublist,
        (segment( WS, Sublist ),
         length(Sublist, L),
         L > 1,
         reverse( Sublist, Sublist )),
    Lists ).

我这里就胆敢去掉了最长的限制,为毛呢?因为现在也就是O(N^3)的时间复杂度的事情,基本满足了去面试写最大回文最糟糕的解法要求。

不过写O(N^3)的正确姿势以前,再放三个臭屁。第一,后来run/1里,作者又求拿到的回文list的长度,其实呢,这事有省点计算的做法,就是前面不要用segment/2,而是用lists库里自带的sublist/5。它的好处就是作为寻找子list的副产品,一点不增加时间复杂度,它就可以给出长度,你存着用就好了。第二,为毛要把测试的字符串放在一个叫ws的fact里,那样侬每次换的字符串,都准备重新load一遍程序吗?!第三,我们来看一眼那个叫做run/1的东西。在一个list里找一个东西(这里不考虑计算回文的长度),不就是遍历一遍么。这样全世界最简单的事情,能够用两次member/2整出O(N^2)的复杂度,还用了大规模杀伤性武器+,不会被三总烧死么?

好吧,下面就是把这些小屁问题们改了之后的程序,虽然这样写估计还是会被Prolog老师砍死,但是起码死了之后不会被拿去碎尸了。

longest_par(List, LongestPar) :-  
        findall(Length-Sub, 
                (sublist(List, Sub, _, Length, _),
                 Length > 1,
                 reverse(Sub, Sub)),
                AllPars),
        find_longest(AllPars, 0, [], LongestPar).

find_longest([], _, Par, Par).

find_longest([L0-Par0|T], L, Par, NewPar) :-  
        (L0 > L
        ->
                find_longest(T, L0, Par0, NewPar)
        ;
                find_longest(T, L, Par, NewPar)
        ).

学Prolog吧,第一件应该会做的事情就是遍历list。[H|T]并不是hentai的意思。。。不过有一点注意,po主我在find_longest/4中用了tail recursion,前面我也提到了reverse/2实现的时候也是这么搞的,为啥涅?是因为Warren老师在WAM中一个重要的东西就是可以把tail recursion执行的时候变成普通的循环,这样就不会因为recursion爆缸内存。。。这句话如果不理解,呃,看paper去。。。

但是这里有个问题就是前面说的使用findall/3,无谓得存储了所有的回文list,结果就是空间复杂度暴增,不是说好了之关心最长的嘛!所以这里用findall/3是木有必要的,最大回文O(N^3)的解法,你其实只是想遍历啊遍历啊,看看当前找到的这个是不是比以前的最长的那个长,根本木有必要把所有的回文都存下来。 所以,呃,就有了这样的代码:

longest_par(List, ParLen, Par) :-  
        length(List, Length),
        longest_par(List, Length, 0, ParLen, [], Par).

longest_par([_], 1, ParLen, ParLen, Par, Par).

longest_par(List, Len, ParLen, NewParLen, Par, NewPar) :-  
        reverse(List, Reverse),
        sub_par(Reverse, Len, ParLen, ParLen0, Par, Par0),
        NewLen is Len - 1,
        (NewLen > ParLen0
        ->
                List = [_H|T],
                longest_par(T, NewLen, ParLen0, NewParLen, Par0, NewPar)
        ;
                NewParLen = ParLen0,
                NewPar = Par0
        ),!.

sub_par([_], 1, ParLen, ParLen, Par, Par).

sub_par(List, Len, ParLen, NewParLen, Par, NewPar) :-  
        ((reverse(List,List),
          Len > ParLen)
        ->
                NewParLen = Len,
                NewPar = List
        ;
                NewLen is Len - 1,
                List = [_H|T],
                sub_par(T, NewLen, ParLen, NewParLen, Par, NewPar)
        ).

程序里一直记录着当前操作的List的长度,是为了减少频繁的length/2的操作。然后因为两个recursion都是tail recursion,最后就是一个二重循环,再加上最里层reverse/2的操作,就是O(N^3)。如果不熟悉cut的用法的,可以自己debug一下这段,看带那个cut和不带的情况下,你否定第一个答案的时候,会有什么结果。然后差不多就大约明白cut的用法了。

其实到此为止,这篇文章的目的已经达到了,就是用脑残的小例子来表达其实写Prolog和其他语言一样,是要考虑用其他语言编程时候的问题的。当考虑这些效率问题的时候,思路也是一样的。不然的话就会给Prolog安上那个莫须有的效率傻逼的名声。不过说起来这倒是Prolog的一个问题,就是你完全不知道WAM和基本的structure copying什么的情况下,其实是很难写出运行效率靠谱的程序的。当然你也可以argue说不管什么语言,不了解点实现细节,都很容易进坑。

下面的事情就纯属是手欠了,就是没事干写了一个O(N^2)的最长回文算法。。。

longest_par(List, Len, Par) :-  
        longest_par(List, [], 0, Len, [], Par).

longest_par([], _, Len, Len, Par, Par).

longest_par([H|T], T0, Len, NewLen, Par, NewPar) :-  
        find_par(T, T0, LenOdd, [H|PROdd], ParOdd, PROdd),
        find_par(T, [H|T0], LenEven, PREven, ParEven, PREven),
        ((LenEven > LenOdd,
          LenEven > Len
         )
        ->
                longest_par(T, [H|T0], LenEven, NewLen, ParEven, NewPar)
        ;(LenOdd > 1,
          LenOdd > Len
         )
        ->
                longest_par(T, [H|T0], LenOdd, NewLen, ParOdd, NewPar)
        ;
                longest_par(T, [H|T0], Len, NewLen, Par, NewPar)
        ),!.


find_par([H|T], [H|RT], Len, T0, PL, [H|T1]) :-  
        find_par(T, RT, Len0, [H|T0], PL, T1),
        Len is Len0 + 2,!.

find_par(_, _, 0, PL, PL, []).  

如果知道O(N^2)的算法的,这个没有什么槽点了,唯一的槽点也就是在6/7两行访问find_par/6的时候,如何通过把第6个argument硬塞给第4个,然后获得整个找到的回文list的。不明的可以debug一下这一块,就能小明一下Prolog的一些hentai的resolution的技术。当然,你也可以argue,1,拿到左右两个list再append/3,也就是O(N)的事情;2,所有具有随机访问数据类型的语言,搞这些根本木有这么麻烦。

好吧,这又引出了Prolog有点让很多其他语言程序员栽坑的地方,那就是没有真正的随机访问数据类型。Sicstus后来提供的那个数组一样的东西logarr,顾名思义也是O(logN)的,并不是O(1)的。然后很多同学又拿list当数组什么的用,就很悲剧了。。。木有随机访问类型还是有时候有些问题的,比如你要用写那个O(N)复杂度的最大回文算法,你用Prolog还真的是真心写不出来。。。

最后胡扯几句。有什么办法来提高Prolog水平呢?呃,因为缺乏这方面的资料,如果人人都去读1980年代的paper也不太合适,那么下面两个办法还是可行的:第一就是前面提到的,看靠谱的Prolog程序,反正很容易找到。不过看SWI要小心,Jan Wielemaker老师的Prolog很漂亮,但是偶尔他不太关心效率;第二就是看史上唯一有内容的Prolog书籍The Craft of Prolog。这本书中讲到了无数Prolog的坑和槽点伟大的写法。不过唯一的问题是作者写这本书的目的其实是吐槽其他语言其他人的教育大家好好写Prolog的,所以结构非常混乱。。。