Jump to 0 top | 1 navigation | 2 content | 3 extra information (sidebar) | 4 footer | 5 toolbar


Content

让我们从游戏开始──点灯(4)

第三天
到目前为止,我们的进展都很顺利。不过我们的代码速度还不是很快,应该还有改进的余地。
昨天我们使用第一行生成了一个包含2^n个序列的list,我们认真看看,就会发现中间很多是对称的,比如n=3的时候100,001对称, 110,011对称。按照我们的算法,这些对称的的序列最后得到的开关桩状态也是对称的,那么我们把每得到一个解,就可以把对称的第一行组合去掉。那么同 理,如果第一行的某一个组合不是解,那么我们也可以把他去掉。这样不就减少了很多的循环次数啦。
首先判断list是否对称:

  1. def symmetric(alist):#判断list是否对称
  2. leng=len(alist)
  3. le=alist[:(leng/2)]
  4. ri=alist[-(leng/2):]
  5. ri.reverse()
  6. if le!=ri:
  7. alist.reverse()
  8. return alist
  9. else:
  10. return None

其他的都一样,我们在昨天的基础上修改一下就好了:

  1. def light(n):
  2. PosSol = [nastr.array(bstr(x).zfill(n), itemsize=1).eval().tolist() for x in xrange(2**n)]
  3. solution=zeros((n, n))#解
  4. tmpArray=zeros((2, n+2))#用来存放每一行的开关状态
  5. try:
  6. for i in range(len(PosSol)):
  7. i=PosSol[i][:]
  8. solution[0:1]=i
  9. tmpArray[0, :]=0
  10. tmpArray[1,1:-1]=i
  11. for line in range(n-1):#取n-1是因为第一行是生成的,不需要计算
  12. solution[line+1:line+2, :]=ones((1, n))-lightstats(tmpArray, n)
  13. tmpArray[:,1:-1]=solution[line:line+2, :]
  14. if sum(lightstats(tmpArray, n))==n:
  15. steps=sum(sum(solution))
  16. print solution
  17. print "Total steps:%d" % steps
  18. rev=symmetric(i)
  19. if rev!=None:
  20. print solution[:,::-1]
  21. print "Total steps:%d" % steps
  22. PosSol.remove(rev)
  23. except:
  24. pass

注意for i in range(len(PosSol))这句,这个是很多初学python的人在循环上犯的错。但是我们这里这么作是有原因的。在如果直接写for i in PosSol,那么在循环体中间对PosSol的操作不会得到你想要的结果,所以我们这样避免了这样的问题。i=PosSol[i][:]是产生一个拷 贝。免得对PosSol里面产生影响。
下面就是代码:

  1. #!/usr/bin/python
  2. # -*- coding: utf8 -*-
  3. #Filename:third2.py

  4. from numarray import *
  5. import numarray.strings as nastr
  6. from bin import bstr

  7. def lightstats(switchstats, n):
  8. TxT = [take(switchstats, [x, x+1, x+2], axis=1) for x in range(n)]
  9. [putmask(item, [1,0,1,0,0,0], [0]) for item in TxT]#去掉不相关的格子
  10. return [(sum(sum(item)))%2 for item in TxT]

  11. def symmetric(alist):#判断list是否对称
  12. leng=len(alist)
  13. le=alist[:(leng/2)]
  14. ri=alist[-(leng/2):]
  15. ri.reverse()
  16. if le!=ri:
  17. alist.reverse()
  18. return alist
  19. else:
  20. return None

  21. def light(n):
  22. PosSol = [nastr.array(bstr(x).zfill(n), itemsize=1).eval().tolist() for x in xrange(2**n)]
  23. solution=zeros((n, n))#解
  24. tmpArray=zeros((2, n+2))#用来存放每一行的开关状态
  25. try:
  26. for i in range(len(PosSol)):
  27. i=PosSol[i][:]
  28. solution[0:1]=i
  29. tmpArray[0, :]=0
  30. tmpArray[1,1:-1]=i
  31. for line in range(n-1):#取n-1是因为第一行是生成的,不需要计算
  32. solution[line+1:line+2, :]=ones((1, n))-lightstats(tmpArray, n)
  33. tmpArray[:,1:-1]=solution[line:line+2, :]
  34. if sum(lightstats(tmpArray, n))==n:
  35. steps=sum(sum(solution))
  36. print solution
  37. print "Total steps:%d" % steps
  38. rev=symmetric(i)
  39. if rev!=None:
  40. print solution[:,::-1]
  41. print "Total steps:%d" % steps
  42. PosSol.remove(rev)
  43. except:
  44. pass

  45. if __name__ == "__main__":
  46. tests = [2,3,4,5,6,7,8,9,10]
  47. for xx in tests:
  48. print "solution for %d*%d" % (xx,xx)
  49. light(xx)

既然可以用对称得到解,那么也可以用对称排除不是解的组合:

  1. def light(n):
  2. PosSol = [nastr.array(bstr(x).zfill(n), itemsize=1).eval().tolist() for x in xrange(2**n)]
  3. solution=zeros((n, n))#解
  4. tmpArray=zeros((2, n+2))#用来存放每一行的开关状态
  5. try:
  6. for i in range(len(PosSol)):
  7. i=PosSol[i][:]
  8. solution[0:1]=i
  9. tmpArray[0, :]=0
  10. tmpArray[1,1:-1]=i
  11. for line in range(n-1):#取n-1是因为第一行是生成的,不需要计算
  12. solution[line+1:line+2, :]=ones((1, n))-lightstats(tmpArray, n)
  13. tmpArray[:,1:-1]=solution[line:line+2, :]
  14. rev=symmetric(i)
  15. if sum(lightstats(tmpArray, n))==n:
  16. steps=sum(sum(solution))
  17. print solution
  18. print "Total steps:%d" % steps
  19. if rev!=None:
  20. print solution[:,::-1]
  21. print "Total steps:%d" % steps
  22. PosSol.remove(rev)
  23. else:
  24. if rev!=None:
  25. PosSol.remove(rev)
  26. except:
  27. pass

这是新的light函数。不过我们想想,我们可以把解进行旋转,得到的就是其他的解,但是不是解的组合不能旋转排除哦,原因嘛,想想就明白了。我们写一个旋转的函数:

  1. def rotatearray(array):
  2. o=zeros(shape(transpose(a)))
  3. for i in range(len(a)):
  4. b[:,i]=a[i,::-1]
  5. return o

那么就是修改以后的light函数:

  1. def light(n):
  2. allSolutions=[]
  3. PosSol = [nastr.array(bstr(x).zfill(n), itemsize=1).eval().tolist() for x in xrange(2**n)]
  4. solution=zeros((n, n))#解
  5. tmpArray=zeros((2, n+2))#用来存放每一行的开关状态
  6. try:
  7. for i in range(len(PosSol)):
  8. i=PosSol[i][:]
  9. solution[0:1]=i
  10. tmpArray[0, :]=0
  11. tmpArray[1,1:-1]=i
  12. for line in range(n-1):#取n-1是因为第一行是生成的,不需要计算
  13. solution[line+1:line+2, :]=ones((1, n))-lightstats(tmpArray, n)
  14. tmpArray[:,1:-1]=solution[line:line+2, :]
  15. if sum(lightstats(tmpArray, n))==n:
  16. steps=sum(sum(solution))
  17. temp=solution
  18. for j in range(2):
  19. for k in range(4):
  20. temp=rotateArray(temp)
  21. if temp.tolist() not in allSolutions:
  22. allSolutions.append(temp.tolist())
  23. print temp
  24. print "Total steps:%d" % steps
  25. try:
  26. PosSol.remove(temp[0, :].tolist())
  27. except:
  28. pass
  29. temp=transpose(temp)
  30. else:
  31. try:
  32. PosSol.remove(rev)
  33. except:
  34. pass
  35. except:
  36. pass

我们去掉了判断对称的函数,直接使用remove,跳过错误信息。定义了allSolutions来避免重复。
现在来总测试一下我们的成果吧:
程序 n 时间

first.py 2~4 real 3m16.271s

second.py 2~10 real 2m32.655s

second2.py 2~10 real 2m38.275s
third.py 2~10 real 2m28.386s

third2.py 2~10 real 1m32.934s
third3.py 2~10 real 1m17.786s

  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

让我们从游戏开始──点灯(3)

第二天
我们完成了一个求解的程序,但是我们还不能高兴,注意到first.py最后的注释没?因为运算量太大,连5×5都完成不了(或者很难完成),就别说更大的了。看来我们还要作一些大的改进才行。
让我们再琢磨琢磨我们的在理论分析中得到的一些信息。嗯~既然灯泡的状态之和“影响格”有关,那么是否意味着开关开启的顺序是无关的?看来我们有了一个突破口。一行一行的求解的可能性是有的,但是我们要怎么作呢?让我们先来分析一下。
我们从上往下考虑,如果先确定了第一行的开关状态,那么能影响这一行的就只有第二行;确定了第二行以后能影响它的就只有第三行……这样类推的话,我们只要 穷举第一行可能的情况,然后从下面一行开始,保证上面一行的灯泡全亮。这样循环下去,到最后一行完成的时候如果就能确定这是否是一个解。看来我们从2** (n*n)已经化简到了2*n。好~好的很。
为了避免不停计算灯泡的状态,我们专门为灯泡状态添加一个表。有了昨天的基础,今天看来可以很快完成。:)
首先得到得到第一行的所有组合:

  1. PosSol = [nastr.array(bstr(x).zfill(n), itemsize=1).eval() for x in xrange(2**n)]

嗯~让我们停下来一下。当我们得到了第一行的灯泡状态以后,我们就确定了第二行的开关状态。由于能影响第一行的只剩第二行的开关,那么我们其实只要 把第一行的灯泡状态取反就得到了第二行的开关状态。对开关状态的判断也简单了,只需要对上两行的灯泡状态判断就可以了,真是比昨天的简单多了。

  1. PosSol = [nastr.array(bstr(x).zfill(n), itemsize=1).eval() for x in xrange(2**n)]
  2.   lightStats=zeros((n, n))#灯泡的状态
  3.   solution=zeros((n, n))#解
  4.   tmpArray=zeros((2, n+2))#用来存放每一行的开关状态
  5.   for i in PosSol:
  6.     solution[0:1]=i.copy()
  7.     tmpArray[0, :]=0
  8.     tmpArray[1,1:-1]=i.copy()
  9.     for line in range(n-1):#取n-1是因为第一行是生成的,不需要计算     
  10.       TxT = [take(tmpArray, [x, x+1, x+2], axis=1) for x in range(n)] 
  11.       [putmask(item, [1,0,1,0,0,0], [0]) for item in TxT]#我们要由上一行得到下一行的状态
  12.       solution[line+1:line+2, :]=[(sum(sum(item))+1)%2 for item in TxT]

然后计算最后一行的灯泡状态得到是否是解:

  1. TxT = [take(tmpArray, [x, x+1, x+2], axis=1) for x in range(n)] 
  2. [putmask(item, [1,0,1,0,0,0], [0]) for item in TxT]
  3. if sum([(sum(sum(item)))%2 for item in TxT])==n:
  4.    print solution
  5.    print "Total steps:%d" % sum(sum(solution))

把重复代码提取出来成一个函数:

  1. def lightstats(switchstats, n):
  2.    TxT = [take(switchstats, [x, x+1, x+2], axis=1) for x in range(n)] 
  3.    [putmask(item, [1,0,1,0,0,0], [0]) for item in TxT]#去掉不相关的格子
  4.    return [(sum(sum(item)))%2 for item in TxT]

好了,配件齐全,开始组装:

  1. #!/usr/bin/python
  2. # -*- coding: utf8 -*-
  3. #Filename:second2.py
  4.  
  5. from numarray import *
  6. import numarray.strings as nastr
  7. from bin import bstr
  8.  
  9. def lightstats(switchstats, n):
  10.    TxT = [take(switchstats, [x, x+1, x+2], axis=1) for x in range(n)] 
  11.    [putmask(item, [1,0,1,0,0,0], [0]) for item in TxT]#去掉不相关的格子
  12.    return [(sum(sum(item)))%2 for item in TxT]
  13.  
  14. def light(n):
  15.    PosSol = [nastr.array(bstr(x).zfill(n), itemsize=1).eval() for x in xrange(2**n)]
  16.    solution=zeros((n, n))#解
  17.    tmpArray=zeros((2, n+2))#用来存放每一行的开关状态
  18.    for i in PosSol:
  19.       solution[0:1]=i.copy()
  20.       tmpArray[0, :]=0
  21.       tmpArray[1,1:-1]=i.copy()
  22.       for line in range(n-1):#取n-1是因为第一行是生成的,不需要计算     
  23.          solution[line+1:line+2, :]=ones((1, n))-lightstats(tmpArray, n)
  24.          tmpArray[:,1:-1]=solution[line:line+2, :]
  25.       if sum(lightstats(tmpArray, n))==n:
  26.          print solution
  27.          print "Total steps:%d" % sum(sum(solution))
  28.  
  29. tests = [2,3,4,5,6]
  30. for xx in tests:
  31.    print "solution for %d*%d" % (xx,xx)
  32.    light(xx)

现在就算算10×10也不会很久啦,哈哈~~不过代码还有可以商量的地方,明天继续。

  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

让我们从游戏开始──点灯(2)

第一天
对于这样的问题,我们最直接的想法就是穷举法:把所有的组合都试一次,有此来找到所有可能的解。
那么我们怎么的到这所有的组合呢?让我们来回想一下前面理论分析的内容。所有灯的状态都用0和1来表示,如果我们把这个4×4的二维数组组合成一个0、1序列,那么我们这个得到的0、1序列的是[0, 2^16)中所有的整数.我们用如下的语句得到这个整数序列:

  1. [x for x in xrange(2**16)]#由于数量比较大,我们没有使用range。两者之间的额区别google一下就可以了。

现在我们把这些整数变成二进制!由于python里面没有直接的函数可以转换,所有我们要自己完成:

  1. bstr = lambda n, l=16: n<0 and binarystr((2L<>1).lstrip('0')+str(n&1) or '0'

>>>bstr(5)
‘101′

注意一下返回的是一个字符串(这个是肯定的,想想就知道了)。zfill可以补齐我们需要的位数。那么生成序列的语句就出来了:

  1. [bstr(x).zfill(16) for x in xrange(2**16)]

现在我们把得到的序列变成数组。numarray是不错的选择。

>>> import numarray.strings as nastr
>>> a=nastr.array(’101010101′, itemsize=1)
>>> a
CharArray([’1′, ‘0′, ‘1′, ‘0′, ‘1′, ‘0′, ‘1′, ‘0′, ‘1′])
>>> numarray.reshape(a,3,3)
CharArray([[’1′, ‘0′, ‘1′],
[’0′, ‘1′, ‘0′],
[’1′, ‘0′, ‘1′]]))

还要把字符转换成数字,用eval就好了。
Hoho, 看来我们需要的东西基本已经齐全了,我们现在先把得到的东西都组合一下。得到下面的代码:

  1. from numarray import *
  2. import numarray.strings as nastr
  3. PosSol=[reshape(nastr.array(bstr(x).zfill(16), itemsize=1),4,4).eval() for x in xrange(2**16)]#PosSol:Possible Solutions

当然,我们现在是限定一个4×4的情况,如果要解n×n,小小改动一下就可以了。如果你对上面的代码的结果有些怀疑,那试试2×2的情况,代码怎么修改相信你应该很清楚,看看结果吧。好了,让我们继续吧。
现在我们有了开关状态的所有组合,那么我们就要根据这些组合计算出灯泡状态。让我们想想灯泡状态的代码要怎么写。我们用x(表示列),y(表示行)表示给 定的灯泡位置,我们根据开关状态计算它是否被点亮。相信很多人的第一想法就是按照给定的x,y得到x+1,x-1,y+1,y-1这些,还要自己判断是否 在给定的元素处于数组的边界……天,我不要再想这种方法了。虽然很直接,但是一点都不好。让我们在脑子里面最后想想这种充满臭味的代码,你难道忍心你的代 码是这样的吗?看来我们要用其他的方法来作,至少看上去要聪明一些的方法。
不如我们不考虑边界的问题,这样不就简单了。:)由于我们计算的是“1”的个数,所以我们把一个4×4的数组加一个由“0”组成的边框。这样就没有“出界”问题了。由于我们的数组是4×4,那么我们这么写:

  1. tmpArray=zeros((6,6))
  2. tmpArray[1:5,1:5]=PosSol.copy()

然后把我们需要的3×3的“抠出来”:

  1. TxT=take(take(tmpArray, [x,x+1,x+2]),[y,y+1,y+2], axis=1)#TxT:Three X Three Square

好了,现在我们只要去掉四个角的数,然后求和判断是不是奇数就知道灯泡是不是点亮了:

  1. putmask(TxT,[1,0,1,0,0,0,1,0,1],0)
  2. value=sum(sum(finalSquare),1)

现在我们全部汇总就得到了游戏的解法,顺便作些小的改动就得到了n×n的算法了:

  1. #!/usr/bin/python
  2. # -*- coding: gb2312 -*-
  3. #Filename:first.py


  4. from numarray import *
  5. import numarray.strings as nastr
  6. from bin import bstr

  7. def light(n):
  8. PosSol=[reshape(nastr.array(bstr(x).zfill(n*n), itemsize=1),n,n).eval() for x in xrange(2**(n*n))]#PosSol:Possible Solutions
  9. tmpArray=zeros((n+2,n+2))
  10. for i in PosSol:
  11. tmpArray[1:n+1,1:n+1]=i.copy()
  12. Neg=False
  13. for x in range(n):
  14. if Neg:
  15. break
  16. for y in range(n):
  17. if Neg:
  18. break
  19. TxT=take(take(tmpArray, [x,x+1,x+2]),[y,y+1,y+2], axis=1)#TxT:Three X Three Square
  20. putmask(TxT, [1,0,1,0,0,0,1,0,1], [0])
  21. value=sum(sum(TxT))
  22. if not (value%2):#找到一个没有点亮就不用判断剩余的了
  23. Neg=True
  24. if not Neg:
  25. print i
  26. print "Total steps:%d" % sum(sum(i))

  27. tests = [2, 3, 4]
  28. for xx in tests
  29. print "solution for %d*%d" % (xx,xx)
  30. light(xx)

太晚了,趁还没下班先把东西收拾了,下班回家睡觉。:)

  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

让我们从游戏开始──点灯(1)

一次上班无聊,把同事的手机拿来打游戏,发现有个叫“点灯”的小游戏,开始不太在意,玩了一会儿以后发现非常有趣,想想很久没有写过程序了,便决定用这个来练练手。
关于游戏
点灯游戏规则非常简单,简单到什么人都可以一学就会。但是简单的规则并不意味着游戏也简单,这恰恰就是我所喜欢的,在简单中充满变化。因为规则简单,我在这里就不多说了。
[swf]http://games.wuhan.cc/flash/flash/flash_swf/3015.swf,1400,550,7,#FFFFFF[/swf]
这个是网上找到的,所不同的就是他在开始的时候有些灯已经点亮了。让我们简化一下,4×4的正方形,一开始所有灯都是关闭状态,我们的目的就是把所有的灯都打开。等我们把这个简化的问题解决了,在来看看这个大的问题。

一些理论
这个游戏真是让人恼火啊,为了让程序能够解出答案,我们需要想想这个游戏到底是什么。
也许我们应该这样看这个游戏:这16个格子是16个开关,每个开关控制着灯泡的状态。如果一个开关的状态改变,那么其所在格子与相邻格子的灯泡状态反转。 这样我们就可以初始化一个4×4,全部是0的数组,表示所有开关全部是关闭的,当某一格的开关打开,我们就把这格变成1。而且我们可以发现,某一格的开关 状态变化偶数次,那么灯泡的状态和最初的状态是一样的(0->1->0)。那么我们用0和1来表示开关的状态是成立的。
好了,现在我们有个一个新的问题:如何从开关状态得到灯泡的状态?问题其实很简单,从上面我们知道,某一格的灯泡受到
自己所在的格子与周围四格的影响(我们为了简单称这些格子叫“影响格”),由于一开始灯泡是关闭的,而且只有两种状态(灯亮,灯灭),所以我们可以知道:对于某一格的灯泡,如果影响格内有奇数个开关打开,这格灯泡就被点亮,反之关闭。
天,我的文笔实在太差,写这些理论真是很痛苦,也不知道有多少人能看懂。
现在我们开始写程序吧。

  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

[转贴]Why Functional Programming Matters

Why Functional Programming Matters
函数式程序设计为什么至关重要


作者: John Hughes
翻译: CloudiDust

[在网上也可以找到其他同学的翻译哦,我翻译完了才看到的,呵呵。]原文地址:http://www.md.chalmers.se/~rjmh/Papers/whyfp.html

--------------------

Why Functional Programming Matters
John Hughes,
Institutionen för Datavetenskap,
Chalmers Tekniska Högskola,
41296 G&oumlteborg, SWEDEN.
rjmh@cs.chalmers.se

--------------------

此 论文作于1984年,作为查麦兹大学的备忘录流传了多年,经过小幅度修订的版本出现于1989年与1990年,即[Hug89]与[Hug90]。此版本 基于原查麦兹大学备忘录的nroff源码,为LaTeX做了改动,使其更接近于印刷版本并纠正了少许错误。请原谅这多少有那么一点点过时的排版吧,文中的 例子也不是Haskell的!

[me: 照理这段原文和内容没多大关系,不过实在手痒啊…… :)]

--------------------

摘要

软 件正在变得越来越复杂,因此良好的软件构架也越来越重要。结构良好的软件易于编写,易于除错,同时提供可复用组件库以降低未来开发的成本。传统型语言在程 序模块化方面具有理念上的局限性,而函数式语言超越了局限。在本文中我们指出,函数式语言的两大特性,高阶函数与惰性求值,能够极大地促进模块化。作为例 证,我们处理了列表和树,编写了一些数值算法,并实现了alpha-beta启发式搜索(一个人工智能算法,用于游戏系统中)。既然模块化是程序设计成功 的关键,那么函数式语言对现实世界而言便极其重要了。

--------------------

1 引言

本论文试图证明,对“现实世界”而言,函数式程序设计是极其重要的。同时,本文也试图明确指出函数式语言的长处,以帮助使用函数式语言的程序员们将这些长处发挥到极致。

函 数式语言之所以被如此称呼,是因为程序完全是由函数组成的。主程序本身也是一个函数,以程序的输入为参数,并返回其输出。典型地,主函数通过其他函数定 义,而这些函数又同样以更多的其他函数来定义,直到最低层的语言原生函数为止。这些函数与数学中的函数很相像,因此在本文中将以普通等式来定义它们。本文 使用了Turner的程序语言Miranda中的表示方法,但对于之前没有函数式语言相关知识的读者,本文仍然是可读的。(Miranda是 Research Software Ltd.的商标。)

函数式程序设计的特性与优点通常总结为类似这样:函数式程序不包含任何赋值语 句,因此变量一旦被赋予一个值,就不再改变。更一般地说,函数式程序不包含任何副作用:一个函数除了计算它本身的值以外,不产生任何作用。这一特性消灭了 “Bug”的一个主要来源,同时也使执行顺序不再重要——没有副作用能够改变一个表达式的值,故它可以在任何时刻被求值。这一特性将程序员从决定控制流的 重担之下拯救出来。由于表达式可以在任何时刻被求值,程序员便可以随心所欲地使用变量的值来代替变量,反之亦然——也就是说,程序是“引用透明”的。这一 自由使得函数式程序与它们传统的对应物相比,更容易数学化地控制。

这样的“优点”列表很不错,但如果说外行人不把它当回事,这也并不会令 人惊讶。它列出了很多内容关于函数式程序设计“没有”什么(它没有赋值,没有副作用,没有控制流)但却没多说它“有”什么。函数式程序员听起来很像是中世 纪的僧侣似的,他们禁绝了尘世中的种种乐趣并且期望这能使自己变得高洁。对于那些更关心物质利益的人而言,这些“优点”并没有多大的说服力。

函 数式程序员们争辩说,函数式程序设计确实有巨大的物质利益——一个函数式程序员拥有比他传统型的同行高得多的生产力,因为函数式程序短得多。但这有什么道 理吗?在这些“优点”的基础之上,唯一的很靠不住的借口就是,传统的程序中有90%是赋值语句,而在函数式程序中这些全都可以省略!这真是太荒唐了,如果 省略赋值语句可以带来如此巨大的好处,那么FORTRAN程序员们早该这样干了二十年了。[me:貌似是说FORTRAN的赋值多得一塌糊涂……]通过省 略特性来使语言更加强大在逻辑上是不可能的,不论这种特性是多么糟糕。

甚至函数式程序员都应该对这些所谓的“优点”表示不满意,因为它们 对于发掘函数式语言的威力毫无帮助。不可能写出一个特别地(particularly)缺少了赋值语句或者特别地引用透明的程序。这些不是什么衡量程序质 量的尺度,因此盯紧了它们(以此证明函数式语言的强大)也不理想。

[me:关于那两个particularly的翻译,还是抓不准……]

很明显,对函数式程序设计的特性的描述是不完备的。我们必须找出一些东西来填补——它们不但要解释函数式程序设计的威力,更要给函数式程序员们一个明确的追求目标。

--------------------

2 与结构化程序设计的相似性

指 出函数式与结构化程序设计之间的相似性是很有帮助的。过去,结构化程序设计的特性与优点被总结为类似这样:结构化程序不包含goto语句;结构化程序中的 语句块没有多入口与多出口;结构化程序与它们传统的对应物相比,更容易数学化地控制。这些结构化程序设计的“优点”与我们之前所谈到的函数式程序设计的 “优点”在本质上很相似。这些叙述本质上都是否定式的,从而导致了诸如“不可或缺的goto”之类一大堆徒劳的争论。

事后诸葛亮式地说,很 明显地,结构化程序设计的这些特性,尽管很有用,但没有触及问题的核心。结构化程序与非结构化程序之间最重要的区别就是,结构化程序是用模块化的方法设计 的。模块化设计带来了生产力的巨大提升:首先,小模块可以很快很容易地编写;其次,通用模块可以被重用,使以后的程序可以更快地开发;再次,程序的模块可 以被独立测试,减少了除错的时间。

“不使用goto”等等这一类特性,对于这一提升没什么作用。这些特性促进了“程序设计的小改良”,然而模块化设计却促进了“程序设计的大进化”。因此,程序员在FORTRAN或汇编语言中都可以享受结构化程序设计带来的好处,哪怕那需要一点额外的工作。

模 块化设计是成功的程序设计的关键,这一观点现在已经被普遍地接受了,而诸如Modula-II[Wir82],Ada[oD80]以及Standard ML[MTH90]之类的程序语言都内置了语言特性以促进模块化。然而,有一点非常重要,却常常被忽略。当编写一个模块化程序以解决问题的时候,程序员首 先把这个问题分解为子问题,而后解决这些子问题并把解决方案合并。程序员能够以什么方式分解问题,直接取决于他能以什么方式把解决方案粘起来。因此,为了 能在观念上提升程序员将问题模块化的能力,必须在程序语言提供中提供各种新的黏合剂。复杂的作用域规则与对分块编译的支持只对文本层面的细节有帮助,它们 没有提供能表达新观念的工具以分解问题。

通过与木匠行业的类比可以认识到黏合剂的重要性。先制作椅子的各部分——坐垫,椅子腿,靠背,等 等——而后用正确的方法钉起来,那么制作一把椅子是很容易的。但这取决于将木板与插接口结合起来的能力。如果缺乏这种能力,那么制作椅子的唯一方式,就是 将它从一大块木头里整个地切割出来,这是一项艰巨得多的任务。这个例子同时表明了模块化的非凡威力与拥有合适的黏合剂的重要性。

现在让我 们回到函数式程序设计上来。在这篇论文余下的部分里,我们将指出,函数式语言提供了两种新的、非常重要的黏合剂。我们将给出许多可以使用新方法模块化的示 例程序,它们因此变得很简洁。这就是函数式程序设计威力的关键——它允许了大幅改进的模块化设计。这也正是函数式程序员必须追求的目标——更小、更简洁、 更通用的模块,用我们将要描述的新黏合剂黏合起来。

--------------------

3 把函数粘起来

两种黏合剂中的第一种,使简单的函数可以聚合起来形成复杂的函数。以一个简单的处理问题来说明:将列表中的元素累加起来。我们用下面的语句定义列表:

listof X :: = nil | cons X (listof X)

这 说明,一个元素类型为X的列表(不论X是什么),或是nil,代表一个没有元素的空列表,或是一个X与另一个X的列表的cons。cons代表一个列表, 其首元素为X,而第二个以及后续元素即是另一个X的列表的元素。此处的X可以代表任何类型——例如,如果X是一个“Integer”(整数类型),那么这 个定义就是说,一个整数列表,或者是空的,或者是一个整数与另一个整数列表的cons。依照通常的实践,我们写列表时,只是简单地将其元素包含在方括号 里,而不是将cons和nil显式地写出来。方便起见,这是一个简单的速记法。例如:

[] 表示 nil
[1] 表示 cons 1 nil
[1 2 3] 表示 cons 1 ( cons 2 ( cons 3 nil ))

列表中的元素可以通过一个递归函数sum进行累加。sum必须为两类参数进行定义:一个空列表(nil),以及一个cons。由于没有数字存在时,累加结果是0,因此我们定义:

sum nil = 0

又因为cons的累加和可以通过将列表的第一个元素加到其余元素的累加和上的方式进行计算,所以可以定义:

sum (cons num list) = num + sum list

检查定义可以发现,计算sum时,只有下面用*标出的部分是特化的:
[me:原文用的是方框,在这里小偷一下懒…… :)]

sum nil = *0*

sum (cons num list) = num *+* sum list

这说明sum的计算可以通过一个通用的递归模式和特化的部分来模块化。这个递归模式习惯上被称为“递减”(reduce),因此sum可以表达为:

sum = reduce add 0

方便起见,reduce传递了一个二元函数add而不是一个运算符。add是这样定义的:

add x y = x + y

只要将sum的定义参数化,我们便可以得到reduce的定义,即:

(reduce f x) nil = x
(reduce f x)(cons a l) = f a ((reduce f x) l)

[me:注意到了吗?这就是Haskell中的foldr啊……]

这 里我们写出了(reduce f x)两边的括号以强调它代替了sum。习惯上括号是省略的,因此 ((reduce f x) l)写作(reduce f x l)。一个三元函数如reduce,当只提供两个参数时,将成为关于那个余下参数的一元函数。一般地,对一个n元函数,提供了m(< n)个参数后,该函数便成为了关于余下的n-m个参数的函数。我们在下文中将遵守这一约定。

用这种方式将sum模块化之后,我们就可以通过对这部分的重用来收获福利了。最有趣的部分就是,reduce可以(直接)用于编写一个函数来计算列表中元素的累乘积,而不需要更多的编程步骤:

product = reduce multiply 1

它也可以用来测试一个布尔值的列表中是否至少有一个元素为真:

anytrue = reduce or false

或者它们是否都为真:

alltrue = reduce and true

理解(reduce f a)的一种方式是,将其看作一个将列表中的所有cons替换为f,将所有nil替换为a的函数。以列表[1,2,3]为例,既然它表示:

cons 1 (cons 2 (cons 3 nil))

那么(reduce add 0)将其转换为:

add 1 (add 2 (add 3 0)) = 6

而(reduce multiply 1)将其转换为:

multiply 1 (mulitiply 2 (mulitiply 3 1)) = 6

于是,很明显地,(reduce cons nil)将复制列表本身。既然将一个列表追加到另一个列表上的方式是将前一个列表的元素cons到后一个列表前部,我们便得到:

append a b = reduce cons b a

例如:

append [1,2] [3,4] = reduce cons [3,4] [1,2]
= (reduce cons [3,4]) (cons 1 ( cons 2 nil ))
= cons 1 ( cons 2 [3,4]))
(将cons替换为cons,将nil替换为[3,4])
= [1,2,3,4]

一个用于将列表中全部元素翻倍的函数可以写作:

doubleall = reduce doubleandcons nil
其中 doubleandcons num list = cons ( 2*num ) list

doubleandcons可以进一步模块化,首先分解为:

doubleandcons = fandcons double
其中 double n = 2*n
fandcons f el list = cons (f el) list

继续分解:

fandcons f = cons . f

其中“.”(函数复合,一个标准运算符)定义为:

(f . g) h = f(g h)

[me:注意这是函数的左复合,可能与你在离散数学课上学到的“右复合”相反。]

为证明fandcons的定义是正确的,我们代入一些参数:

fandcons f el = (cons . f) el
= cons (f el)
因此 fandcons f el list = cons (f el) list

最终得到的版本是:

doubleall = reduce (cons . double) nil

继续模块化,我们得到:

doubleall = map double
map f = reduce (cons . f) nil

其中map使任意的函数作用于列表的全部元素之上。map是另一个很通用的函数。

我们甚至可以写出一个函数累加矩阵中的所有元素,该矩阵用列表的列表表示。这个函数是:

summatrix = sum . map sum

map sum使用函数sum分别计算所有行的元素之和,而后最左边的sum将每一行的元素之和累加起来,从而得到整个矩阵的累加和。

这 些例子应该已经足以使读者确信,一点模块化的努力可以产生很大的效果。通过将一个简单的函数(sum)模块化为一个“高阶函数”与一些简单参数的聚合,我 们得到了一个部件(reduce),它可以用于编写与列表有关的许多函数,而又不再需要(更多的)编程努力。不止是对有关列表的函数可以这么干,举另外一 个例子,考虑数据类型“有序标记树”,其定义是:

treeof X ::= node X (listof (treeof X))

这个定义表明,一棵X的树,由一个标记类型为X的结点(node),以及一个子树列表组成,而这些子树也是X的树。例如,树:

1 o
/\
/ \
/ \
2 o o 3
|
|
|
o 4

可以被表示成:

node 1
(cons (node 2 nil)
(cons (node 3
(cons (node 4 nil) nil))
nil))

我 们不再给出一个函数例子并将它抽象为高阶函数,取而代之的是,直接给出一个类似于reduce的函数redtree。回忆一下,reduce有两个参数, 一个用于取代cons,另一个用于取代nil。既然树由node,cons和nil组成,那么redtree必须有三个参数——用于分别取代上述三者。由 于树和列表不是同一种类型,我们得定义两个函数分别处理它们。因此我们定义:

redtree f g a (node label subtrees) =
f label (redtree' f g a subtrees )
redtree' f g a (cons subtree rest) =
g (redtree f g a subtree) (redtree' f g a rest)
redtree' f g a nil = a

[me:这相当于f取代node,g取代cons,a取代nil。:)]

很多有趣的函数都可以通过把redtree和其他函数粘起来的方法来定义。例如,要把一棵数字树上的所有标记累加起来,可以使用:

sumtree = redtree add add 0

以我们刚才表述的那棵树为例,sumtree展开成:

add 1
(add (add 2 0)
(add (add 3
(add (add 4 0) 0))
0))
= 10

要生成一个包含树中全部标记的列表,可以用:

labels = redtree cons append nil

仍然是那个例子,得到:

cons 1
(append (cons 2 nil)
(append (cons 3
(append (cons 4 nil) nil))
nil))
= [1,2,3,4]

最后,可以定义一个类似于map的函数,此函数使函数f作用于树中的全部标记上:

maptree f = redtree (node . f) cons nil

以 上这些操作之所以可行,是因为函数式语言允许将传统型语言中不可分解的函数表达为一些部件的聚合——也就是一个泛化的高阶函数与一些特化函数的聚合。这样 的高阶函数一旦定义,便使得很多操作都可以很容易地编写出来。不论何时,只要一个新的数据类型被定义,就应当同时定义用于处理这种数据的高阶函数。这样就 简化了对数据类型的处理,同时也将与它的表示细节相关的知识局部化了。[me:个人感觉这相当于OO里的封装。]与(函数式语言)最相像的传统程序语言是 可扩展语言——只要有需求,这种程序语言就好像随时都可以扩展出新的控制结构一样。[me:原文是The best analogy with conventional programming is with extensible languages - it is as though the programming language can be extended with new control structures whenever desired.不太有把握……]

--------------------

4 把程序粘起来

函数式语言提供的另一种黏合剂使得所有程序都可以粘在一起。回忆一下,一个完整的函数式程序只不过是一个从输入映射到输出的函数。如果f和g是这样的程序,那么对程序(g.f)当提供了输入参数input之后,得到:

g (f input)

程 序f计算自身的输出,此输出被用作程序g的输入。传统上,这是通过将f的输出储存在临时文件中实现的。这种方法的毛病是,临时文件可能会占用太大的空间, 以至于将程序黏合起来变得很不现实。[me:但也不要忘了神奇的Unix管道……也许管道就是下面说的这种方法的另一个实现?:)]函数式语言提供了一种 解决方案。程序f和g严格地同步运行,只有当g试图读取输入时,f才启动,并且只运行足够的时间,恰好可以提供g需要读取的输出数据。而后f将被挂起,g 将继续执行,直到它试图读取另一个输入。一个额外的好处是,如果g没有读取完f的全部输出就终止了,那么f也将被终止。f甚至可以是一个不会(自行)终止 的程序,它可以产生无穷多的输出(而不会出现问题),因为当g运行结束时,f也将被强行终止。这就使得终止条件可以与循环体
分离——一种强大的模块化形式。

这 种求值方式使得f尽可能地少运行,因此被称为“惰性求值”。它使得将程序模块化为一个产生大量可能解的生成器与一个选取恰当解的选择器的方案变得可行。有 些其他的系统也允许程序以这种方式运行,[me:看来就是说的管道啦!]但只有函数式语言对每一个函数调用都一律使用惰性求值,使得程序的每个部分都可以 用这种方式模块化。惰性求值也许是函数式程序员的拿手利器中威力最大的模块化工具。

4.1 牛顿-拉夫森求根法

我们将编写一些数值算法以展现惰性求值的威力。首先,考虑用于求解平方根的牛顿-拉夫森算法。该算法从一个初始的近似值a0开始计算数N的平方根,为了求得更好的解,它使用下述规则


a(n+1) = (a(n) + N/a(n)) / 2

如果近似值序列趋近于某一个极限a,那么

a = (a + N/a) / 2
故 2a = a + N/a
a = N/a
a*a = N
a = squareroot(N)

事实上,这个近似值序列确实迅速地趋近于一个极限。平方根算法取一个允许误差(eps)为参数,当两个相邻的近似值之差(的绝对值)小于eps时,算法便终止了。

这个算法通常被编写为类似下面这样:

C N IS CALLED ZN HERE SO THAT IT HAS THE RIGHT TYPE
X = A0
Y = A0 + 2.*EPS
C THE VALUE OF Y DOES NOT MATTER SO LONG AS ABS(X-Y).GT.EPS
100 IF (ABS(X-Y).LE.EPS) GOTO 200
Y = X
X = (X + ZN/X) / 2
200 CONTINUE
C THE SQUARE ROOT OF ZN IS NOW IN X

[me:这是一段FORTRAN的程序,C代表注释行,保留不翻译。.LE.是“Less than or Equal to”(小于或等于)的缩写,同理.GT.是“大于”的意思。]

在传统型语言中,这个程序是不可分解的。我们将利用惰性求值将其化为更加模块化的形式,而后演示所生成部件的一些其他用途。

由于牛顿-拉夫森算法计算的是一个近似值的序列,故将它写作一个使用近似值列表的程序就再自然不过了。每个近似值都可以通过下面的函数从前一个值计算得到:

next N x = (x + N/x) / 2

因此(next N)是从一个近似值映射到下一个值的函数。调用函数f,得到近似值序列:

[a0, f a0, f(f a0), f(f(f a0)), ...]

我们可以定义一个函数来计算:[me:这是通用的迭代计算。]

repeat f a = cons a (repeat f (f a))

因此 近似值序列可以这样计算:

repeat (next N) a0

repeat是一个具有“无穷”输出的函数的例子——但这没关系,因为超出程序其余部分需求的近似值并不会被计算。无穷性只是潜在的:它只说明,只要有需求,就可以计算出任意数量的近似值,repeat本身不会强加任何限制。

求根函数的剩余部分是函数within,它取一个允许误差与一个近似值列表作为参数,并在列表中查找差值不超过允许误差的一对相邻的近似值。这个函数可以定义为:

within eps (cons a (cons b rest)) =
= b, if abs(a-b) <= eps
= within eps (cons b rest), otherwise

将这两个部件结合起来,

sqrt a0 eps N = within eps (repeat (next N) a0)

现 在我们得到了求根函数的两大部件,便可以尝试用不同的方式组合它们。将要进行的修改之一,是将判断条件改为“相邻近似值的比趋近1”而不是“差趋近0”。 这对于非常小的数字而言更加合适(当初始的相邻近似值之间的差值很小时),对非常大的数字也是如此(当舍尾产生的误差比允许误差大很多时)。我们只需要定 义一个函数来替换within:

relative eps (cons a (cons b rest)) =
= b, if abs(a-b) <= eps*abs b
= relative eps (cons b rest), otherwise

[me:注意:relative里的eps与within里的eps定义是不同的!前者是绝对误差后者是相对误差!]

而并不需要改写生成近似值的部件。

4.2 数值微分

我们已经重用了平方根近似值序列,当然,对函数within和relative的重用也是可能的,它们能够与任何一个生成近似值序列的数值算法配合。我们将这样来编写数值微分算法。

函数在某一点的微分,便是其图象在该点的斜率。通过分别计算函数在该点与一个临近点处的取值,而后计算两点连线斜率的方法,可以很容易地估计出微分的值。这基于一个假定:如果这两点靠得足够近,那么函数图象在两点之间不会弯曲得很厉害。于是有下述定义:

easydiff f x h = (f(x+h)-f x) / h

为 了得到良好的近似值,h应该很小。不幸的是,如果h太小,那么f(x+h)与f(x)会相当接近,因此在相减过程中产生的舍尾误差可能会掩盖了计算结果。 如何为h选取恰当的值呢?解决这个矛盾一种方案是从一个合理的较大取值开始,不断减小h的值,并求出一个(微分的)近似值序列。这个序列将趋近于该点的导 数,但最终会由于舍尾误差的存在而不可救药地变得不精确。如果我们用(within eps)来选取第一个足够精确的近似值,那么舍尾误差影响结果的风险将会大大降低。我们需要一个函数来计算这个序列:

differentiate h0 f x = map (easydiff f x) (repeat halve h0)
halve x = x/2

此处h0是h的初值,而后继取值是通过不断减半得到的。通过这个函数,任意点处的导数可以这样计算:

within eps (differentiate h0 f x)

但是,甚至这个方案也不是那么令人满意的,因为近似值序列收敛得相当慢。解决这个问题需要一点数学知识,序列中的元素可以记为:

(微分的)精确值 + 一个关于h的误差项

理论表明,该误差项与h的某一次幂大致成正比,因此当h减小时,误差也会减小。设精确值为A,而误差项为B*h**n [me:**是求幂运算符]。由于计算每个近似值时所用的h取值是下一个的两倍,故任意两个连续的近似值可以表示成:

a(i) = A + B*(2**n)*(h**n)
a(i+1) = A + B*(h**n)

现在就可以消去误差项了,我们得到:

a(i+1)*(2**n) - a(i)
A=----------------------
2**n - 1

当然,误差项只不过“大致”与h的某一次幂(成正比),因此这个结论也是近似的。但这是一个好得多的近似。这一改进可以通过下述函数作用于所有相邻的近似值对之上:

elimerror n (cons a (cons b rest)) =
= cons ((b*(2**n)-a)/(2**n-1)) (elimerror n (cons b rest))

从一个近似值序列中消除误差项的操作产生了另一个收敛速度快得多的序列。

使用elimerror之前还有一个问题需要解决——我们必须知道n的正确值。通常这个值很难预测,但却很容易衡量。不难验证,下述函数能够正确地消除误差项,但在此我们并不给出证明。

order (cons a (cons b (cons c rest))) =
= round(log2( (a-c)/(b-c) - 1 ))
round x = 最接近x的整数
log2 x = x以2为底的对数

现在,一个通用的近似值序列优化函数可以定义为:

improve s = elimerror (order s) s

使用improve能够更加高效地计算函数f的导数,如下:

within eps (improve (differentiate h0 f x))

improve只对利用一个不断减半的参数h计算得到的近似值序列
适用。但是,如果improve作用于这样的序列,那么其结果也是一个这样的序列!这意味着一个近似值序列可以优化不止一次。每一次优化的过程中,都有一个不同的误差项被消除,因此优化产生的序列收敛得越来越快。因此,可以非常高效地计算导数:

within eps (improve (improve (improve (differentiate h0 f x))

从数值分析的角度讲,这似乎是一个“第四阶方法”[me:fourth order method,我还没有学数值分析,不会翻译……],可以迅速地给出准确的结果。甚至可以定义:

super s = map second (repeat improve s)
second (cons a (cons b rest)) = b

super 函数使用repeat improve来生成一个不断被优化的近似值的序列的序列。[me:就是说,生成一个序列,其中每一个元素是一个近似值序列,而这个元素是用前一个元素优 化得到的。]同时,super提取出每个近似值序列中的第二个元素,构造出一个新的序列(已经确认,第二元素是最佳选择——它比首元更精确,而且不需要额 外的计算)。这个算法的确非常复杂——更多的近似值被计算的同时,它使用了不断优化的数值方法。可以用下面的程序非常非常高效地计算导数:

within eps (super (differentiate h0 f x))

这个案例可能就像是用大锤敲碎坚果一样(大材小用),但关键是,甚至一个像super一样复杂的函数,当被惰性求值的方法模块化时,也会变得很容易表达。


4.3 数值积分

在 这一部分我们将讨论的最后一个例子是数值积分。问题的描述很简单:给出一个返回实数,并有一元实数参数的函数,以及两个端点a和b,估算两点之间曲线f下 方的面积。[me:感觉不够准确……可能我对under和area的理解错了?]估算面积的最简单方法是假定f趋近于直线,此时面积就是:

easyintegrate f a b = (f a + f b)*(b-a)/2

不 幸的是,除非a与b足够接近,否则这个估算似乎非常不精确。更好的估算方法是,将a与b之间的区间分为两段,分别估算子区间上的面积,再将结果加起来。我 们可以定义一个不断趋近于准确值的积分近似值序列,首先使用上述方程进行第一次近似,而后将分别趋近于两个子区间上的子积分准确值的(两个)近似值累加起 来以得到新的(积分总体的)近似值。[me:翻译有点乱,简单说,二分法。]计算这个序列可以使用函数:

intergrate f a b = cons (easyintergrate f a b)
(map addpair (zip (intergrate f a mid)
(intergrate f mid b)))
式中 mid = (a+b)/2

zip是另一个标准的表处理函数。它读取两个列表,并返回一个有序对的列表,每个有序对由两个输入列表中对应的元素组成。从而第一对由列表一和列表二的首元组成,第二对由列表一和列表二的第二个元素组成,以此类推。zip可以定义为:

zip (cons a s) (cons b t) = cons (pair a b) (zip s t)

在函数intergrate中,zip用于生成由两个子区间上相对应的积分近似值对组成的列表,而map addpair用于将有序对中的元素相加,从而生成一个原积分的近似值列表。

实 际上,这个版本的intergrate函数相当低效,因为它持续不断地重复计算f的值。就像所写的一样,easyintergrate计算了f在a和b两 处的值,而对intergrate的递归调用将重复计算它们。同样的,(f mid)也在递归调用中重复计算了。因此,更可取的是下述从不重复计算f的版本:

intergrate f a b = interg f a b (f a) (f b)
integ f a b fa fb = cons ((fa+fb)*(b-a)/2)
(map addpair (zip (interg f a m fa fm)
(interg f m b fm fb)))
式中 m = (a+b)/2
fm = f m

integrate给出了一个不断趋近准确值的积分近似值列表,正如differentiate在上一小节中所做的一样。因此可以写出计算式以求出所需任意精度的积分值,如下:

within eps (intergrate f a b)
relative eps (integrate f a b)

这 个积分算法与上一小节中的第一个微分算法有着同样的缺点——它收敛得相当慢。序列中的第一个近似值仅仅用了两个相距(a-b)的点来计算(通过 easyintergrate)。第二个近似值也(除了a、b之外)用到了中点,因此相邻两点之间的间距仅为(b-a)/2。第三个近似值在两个子区间上 作同样的处理,因此间距仅为(b-a)/4。很清楚,每个近似值对应的相邻两点之间的间距在计算下一个值时被减半了。将这一间距看作“h”,那么这个序列 就可以成为上一小节中定义的“improve”函数的优化对象了。因此我们可以写出(函数来计算)快速收敛的积分近似值序列,例如:

super (intergrate sin 0 4)

improve (intergrate f 0 1)
式中 f x = 1/(1+x*x)

(后一个序列是用于计算pi/4的“第八阶方法”[me:……]。其中的第二个近似值只需要计算5次f的取值,但却具有5位准确数字。)

在 本节中我们选取了一些数值算法并将它们函数化地编写出来,把惰性求值当做了黏合部件的黏合剂。由于惰性求值的存在,使得我们可以用很多新的方式来模块化这 些算法,从而产生用途广泛的函数,例如within,relative和improve。通过这些部件的不同组合,我们简单而明了地编写出了一些相当不错 的数值算法。

--------------------

5 人工智能中的例子

我们已经指出,函数式语言威力强大主要是因为它们提供了两种新的黏合剂:高阶函数和惰性求值。在本节中,我们将讨论人工智能中一个大一点的实例,并演示如何使用这两种黏合剂来十分简单地编写它。

我们选取的实例是alpha-beta“启发式搜索”,一个用于估计游戏者所处形势好坏的算法。该算法预测游戏局势的可能发展,但会避免对无意义局势的进一步探究。

令游戏局势使用“position”类型的对象来表示。这个类型依据游戏的不同而不同,我们不对此作任何假定。必然有一种方法可以知晓对某一个局势能够采取的行动:假定有一个函数:

moves: position -> listof position

该函数以一个游戏局势为参数,并返回一个可以由自变量出发,通过一步行动而形成的position的列表。以noughts and crosses游戏(tic-tac-toe)为例:

| | x| | |x| | |
-+-+- -+-+- -+-+- -+-+-
moves | | = [ | | , | | , |x| ]
-+-+- -+-+- -+-+- -+-+-
| | | | | | | |

| | o| | |o|
-+-+- -+-+- -+-+-
moves |x| = [ |x| , |x| ]
-+-+- -+-+- -+-+-
| | | | | |

这个函数假定通过当前局势总是可以判定现在是哪位游戏者的回合。在noughts and crosses中,可以通过数出“0”与“X”的数目来做到这一点。在类似于象棋的游戏中,可能必须在“position”类型中显式包含这一信息。

利 用函数moves,第一步是构造一棵博弈树。这棵树的结点都用局势来标记,而一个结点的子结点用从该结点一步便可到达的局势标记。也就是说,如果一个结点 标记为局势p,那么它的子结点将使用(moves p)中的局势来标记。一棵博弈树完全有可能是无穷的,如果这个游戏可以在双方都不胜的情形下永远进行下去的话。博弈树与第2节中讨论的树完全类似——每个 结点都有一个标记(它所代表的局势)与一个子结点列表。因此我们可以使用相同的数据类型来表示它们。

博弈树是通过反复运用moves而构造出来的。构造从根局势开始,moves用于生成根结点处子树的标记,而后moves被用于生成子树的子树,依此类推。这一递归模式可以用一个高阶函数表示:

reptree f a = node a (map (reptree f) (f a))

使用这个函数可以定义另一个函数,该函数从一个特定的局势开始生成博弈树:

gametree p = reptree moves p

例如图1所示。此处使用的高阶函数(reptree)与上一节中用于构造无穷列表的函数repeat是类似的。

| |
-+-+-
gametree | |
-+-+-
| |
| |
-+-+-
= | |
-+-+-
| |
/ | \
/ | \
/ | \
/ | \
/ | \
/ | \
X| | |X| | |
-+-+- -+-+- -+-+-
| | | | |X|
-+-+- -+-+- -+-+-
| | | | | |
/|\ /|\ /\
... ... / \
/ \
/ \
O| | |O|
-+-+- -+-+-
|X| |X|
-+-+- -+-+-
| | | |
/|\ /|\
... ...

图1: 一棵博弈树的实例


alpha -beta算法从一个给定的局势出发,就游戏的发展将会是有利还是不利作出判断。然而,要做到这一点,它必须能够在不考虑下一步的情况下粗略地估计某一个 局势的“价值”。在后继局势不可预测时必须使用这一函数,它也可以用来对算法进行先期引导。静态估价的结果是从计算机的角度考虑的,是对该局势的前途的度 量(假设在游戏中计算机与人对抗)。结果越大,局势对计算机而言越好。结果越小,局势越糟。最简单的此类函数将会,比如说,对计算机确定胜利的局势返回+ 1,对计算机确定失败的局势返回-1,而对其它的局势返回0。在现实中,静态估价函数会衡量各种使局势“看上去不错”的因素。例如,具体的好处,以及象棋 中对中心的控制。假定有这样一个函数:

static: position -> number

既然一棵博弈树是一个(treeof position),那么它就可以被函数(maptree static)转换为一个(treeof number),该函数对树中所有的(也许是无穷多个)局势进行静态估价。此处使用了第2节中定义的函数maptree。

给 出一棵静态估价树之后,其中各个局势的真值究竟是多大?特别地,对根局势应该赋予什么值?不是它的静态值,因为那只是一个粗略的猜测。一个结点被赋予的 值,必须由其子结点的真值决定。这一过程的完成,基于每个游戏者都会选择对自己最有利的行动的假定。回忆一下,高值意味着计算机的有利形势。很明显,当计 算机从任意的局势开始下一步行动时,它将选择通往真值最高的子结点的行动。类似地,对手将会选择通往真值最低的子结点的行动。假定计算机与其对手轮流行 动,那么当轮到计算机行动时,节点的真值用函数maximise计算,反之用minimise计算。

[me:所谓“真值”(true value),可能是我翻译得不好,此处理解为类似“真正的价值”的意思吧,是一个量度,不是逻辑学里的0和1哦。]

maximise (node n sub) = max (map minimise sub)
minimise (node n sub) = min (map maximise sub)

此 处max和min是关于列表的函数,分别返回列表中元素的最大值与最小值。上述定义是不完整的,因为它们将永远递归下去——没有给出边界情形。我们必须定 义没有后继的结点的值(其标记)。因此静态估价用于任一游戏者胜利或者后继局势不可预测的情况下。maximise与minimise的完整定义是:

maximise (node n nil) = n
maximise (node n sub) = max (map minimise sub)
maximise (node n nil) = n
maximise (node n sub) = min (map minimise sub)

在这个阶段,几乎已经可以写出一个取一个局势作为参数并返回其真值的函数了。可能是:

evaluate = maximise . maptree static . gametree

这 个定义有两个问题。首先,它不适用于无穷树。maximise不断地递归直到找到一个没有子树的结点——树的端点。[me:还是叫叶结点习惯啊……]如果 没有端点那么maximise就不会返回结果。第二个问题与第一个有关——甚至有穷的博弈树(如noughts and crosses里的那棵)事实上也可能相当大。估价整棵博弈树是不现实的——搜索必须被限定在接下去的几步之内。为此可以将树剪至一个固定的深度:

prune 0 (node a x) = node a nil
prune n (node a x) = node a (map (prune (n-1)) x)

(prune n)取一棵树作为参数并“剪去”与根结点的距离超过n的所有结点。如果一棵博弈树被剪枝,那么将强制maximise对深度为n的结点执行静态估价而不是进一步递归。因此evaluate可以被定义为:

evaluate = maximise . maptree static . prune 5 . gametree

这将考虑其后(比如说)5步的形势。

在 此开发过程中我们已经使用了高阶函数与惰性求值。高阶函数reptree和maptree使得我们能够很容易地构造与处理博弈树。更重要的是,惰性求值确 保了我们可以使用这种方式模块化evaluate。由于博弈树具有潜在的无穷结果,在没有惰性求值的情况下,程序将永远不会终止。我们将不能写:

prune 5 . gametree

而不得不将这两个函数整合成一个只构造树的前五层的函数。更糟糕的是,甚至那前五层都可能已经太大以至于无法在同一时间内存储于内存中。而在我们所写的程序中,函数

maptree static . prune 5 . gametree

只 是构造出了树中maximise所需的部分。由于每一部分都可以在被maximise处理完之后丢弃(被垃圾收集器回收),故完整的树从来没有存储于内存 中。只有树的一小部分在某一段时间内被储存着。因此这个惰性程序很有效率。这一效率取决于maximise(组合链上的最后一个函数)与gametree (第一个函数)的相互作用,因此在没有惰性求值的情况下,要完成任务,只能将组合链上的所有函数整合成一个大函数。这是对模块化的强烈破坏,但也是通常的 做法。通过单独修补每个部件,我们就可以优化估价算法——这相对简单。而一个传统型程序员必须把整个程序作为一个单元来修改,这就困难多了。

到目前为止,我们只是描述了简单的对最大最小值的处理(minimaxing)。但alpha-beta算法的核心是“计算maximise与minimise的值时常常不需要考虑整棵树”这一观察结果。考虑树:

max
/ \
/ \
/ \
/ \
min min
/ \ / \
/ \ / \
1 2 0 ?

相当奇怪地,为了估价这棵树,并不需要知道问号处的值。左子树的最小值是1,但右子树的最小值显然是一个小于或等于0的值。因此这两个最小值的最大值必然是1。这一观察结果可以被泛化并内建到maximise和minimise之中。

第一步是将maximise拆分成max对一个数字列表的作用。也就是,将maximise分解为:

maximise = max . maximise'

(minimise 可以用类似的方法分解。由于maximise和minimise是完全对称的,故我们将只讨论maximise,而假定minimise也照此处理。)一 旦这样分解之后,maximise可以使用minimise'来发现minimise将对哪些数字求最小值,并且不再使用minimise本身。而后便可 以在不查看某些数字的情况下便将它们丢弃。由于惰性求值的存在,如果maxmise并不会查看所有的数字列表,那么一部分列表将不会被计算,这是对计算机 时间的潜在节约。

将max从maximise中“约分出来”是很简单的,得到:

maximise' (node n nil) = cons n nil
maximise' (node n l) = map minimise l
= map (min . minimise') l
= map min (map minimise' l)
= mapmin (map minimise' l)
式中 mapmin = map min

由 于minimise' 返回一个数字列表,而这个列表的最小值是minimise的结果,故(map minimise' l)返回一个数字列表的列表。Maximise'应该返回这些列表中每个列表的最小值组成的列表,但只有其中[Maximise的返回值中]的最大值才有 用。我们应该定义一个mapmin的新版本以忽略那些最小值不重要的列表[在(map minimise' l)的返回值中]的最小值。

mapmin (cons nums rest) =
= cons (min nums) (omit (min nums) rest)

函数omit传递一个“潜在的最大值”——当前所发现的最小值中最大的一个——并忽略任何比该值小的最小值。

omit pot nil = nil
omit pot (cons nums rest) =
= omit pot rest, if minleq nums pot
= cons (min nums) (omit (min nums) rest), otherwise

minleq 以一个数字列表和一个潜在最大值为参数,如果列表的最小值小于或等于潜在最大值就返回真。要完成这一工作,它并不需要扫描整个列表!如果列表中有任意一个 元素小于或等于潜在最大值,那么列表的最小值肯定也是如此。该特别元素之后的所有元素都是无关紧要的——它们就像是上面例子中的问号一样。因此 minleq可以被定义为:

minleq nil pot = false
minleq (cons num rest) port = true, if num<=pot
= minleq rest pot, otherwise

如是定义了maximise'和minimise'之后,要写出一个新的估价函数就很简单了:

evaluate = max . maximise' . maptree static . prune 8 . gametree

由于惰性求值的存在,使得maximise'只查看树的更小部分,这意味着整个程序会更加高效,正如prune只查看无穷树的一部分使得程序可以终止一样。对maxmise'的优化,尽管相当简单,却能对运算的速度产生戏剧性的效果。因此也使得估价函数可以看得更远。

[me: 刚看完的时候想,maximise'干嘛还要返回一个包含了那个最大值的列表呢,这既然很显然会是一个升序表,那么只要保留pot遍历完了直接当最大值返 回不就结了……然后发现那样的话在返回值上和maximise就一样了啊……而且参数也一样,那不就是maximise了嘛……那我们还在这里搞什么 呢?:)]

对估价函数还可以进行其它优化。例如,alpha-beta算法在最佳行动被最先考虑时,能够将工作描述得(进行得?)最好, 因为如果有一方发现了一着妙棋,那就没必要再考虑较差的行动了,除非他证明对手至少能有一种很好的回应方式。因此可能会希望对每一个结点的子树进行排序, 当计算机行动时将最高值放在第一,而人行动时则相反。这可以使用函数:

highfirst (node n sub) = node n (sort higher (map lowfirst sub))
lowfirst (node n sub) = node n (sort (not.higher) (map highfirst sub))
higher (node n1 sub1)(node n2 sub2) = n1>n2

此处sort是多用途排序函数。现在估价函数定义为:

evaluate = max . maximise' . highfirst . maptree static . prune 8 . gametree

[me:我一开始没想明白为什么highfirst是对静态估价产生的树排序,而不是根据真值树排序呢?后来才想起来根本就不存在返回真值树的函数啊……:)]

也可能认为,为了限制搜索,只要考虑计算机或者对手的前三个最佳行动也已经足够了。要编写这样的程序,只需要把highfirst换成(taketree 3 . highfirst),其中:

taketree n = redtree (nodett n) cons nil
nodett n label sub = node label (take n sub)

taketree 将树上所有的结点替换为最多有n个子结点的结点,它使用了函数(take n),而该函数返回列表的前n个元素(如果列表比n短,那么返回的元素就少一些)。

另 一种优化是对剪枝的改良。上述程序甚至在局势非常dynamic[me:晕……“动态”?实在不知道怎么翻译了……难道是“明朗”?]的情形下也会向前搜 索固定的深度——(但是,)例如在国际象棋中,一旦皇后被威胁,也许就可以决定不再搜索了。通常可以定义某些“dynamic”的形势,并在遇到这样的结 点之一时,不再继续搜索而停止。假定有函数“dyramic”用以确定这样的形势,那么只需要为prune追加一个定义等式:

prune 0 (node pos sub) = node pos (map (prune 0) sub), if dynamic pos

[me:原文如此,但大伙儿不觉得那开头的prune 0应该是prune n吗?]

在 像这个程序一样模块化的程序里,作出这样的改动是很简单的。如前所述,这个程序的效率,关键是由链中的最后一个函数maximise与第一个函数 gametree的相互作用决定的,因此若没有惰性求值,就只能写成一个单一的程序。这样的程序难于编写,难于修改,而且,非常难于理解。

--------------------

6 结论

在 本论文中,我们指出,模块化是成功的程序设计的关键。以提高生产力为目标的程序语言,必须良好地支持模块化程序设计。但是,新的作用域规则和分块编译的技 巧是不够的——“模块化”不仅仅意味着“模块”。我们分解程序的能力直接取决于将解决方案粘在一起的能力。为了协助模块化程序设计,程序语言必须提供优良 的黏合剂。函数式程序语言提供了两种新的黏合剂——高阶函数与惰性求值。利用这些黏合剂可以将程序用新的、令人激动的方式模块化,对此我们举出了很多实 例。越小、越通用的模块越可能被广泛地重用,使后续的程序设计工作变得简单。这解释了为什么函数式程序与传统型程序比较,要小得多,也容易编写得多。它也 为函数式程序员提供了一个追求目标。如果程序的任何部分是杂乱或者复杂的,那么程序员就应当尝试将其模块化并泛化其部件。他应当期望把高阶函数和惰性求值 用作他做此事的工具。

当然,我们并不是指出高阶函数与惰性求值的力与美的第一人。例如,Turner展示了这两者如何在一个生成化学结构 的程序里大显身手[Tur81]。Abelson和Sussman强调“流”(惰性列表)是构架程序的强大工具[AS86]。Henderson使用了流 来构架函数式操作系统[P.H82]。本论文的主要贡献是,断言了模块化自身,便是函数式语言强大威力的关键。

这与当前有关惰性求值的论 战也有关联。有些人认为函数式语言应当是惰性的,而其他人认为不是这样。有些人走折衷路线,只提供惰性列表以及用于构造它们的特殊语法(例如,在 SCHEME中[AS86])。本论文提供了更进一步的证据,证明惰性求值非常重要以至于不能被降为二等公民。这也许是函数式程序员所拥有的最强大的黏合 剂。人们不应当阻碍对这样一个极为重要的工具的使用。

--------------------

致谢

在 牛津程序设计研究组与Phil Wadler和Richard Bird的多次交谈对本论文的写作帮助甚大。约特堡查麦兹大学的Magnus Bondesson指出了一个数值算法的早期版本中的严重错误,同时也协助了很多其他算法的开发。本论文在英国科学与工程研究评议会提供的研究基金赞助下 发表。

--------------------

参考文献

[AS86] H.Abelson,G.J.Sussman. 计算机程序的构造与解释. 麻省理工学院出版社,波士顿,1986

[Hug89] J.Hughes. 函数式程序设计为什么至关重要. 计算机月刊,32(2),1989

[Hug90] John Hughes. 函数式程序设计为什么至关重要. D.Turner主编,函数式编程的研究主题. Addison Wesley,1990

[MTH90] R.Milner,M.Tofte,R.Harper. Standard ML的定义. 麻省理工学院出版社,1990

[oD80] 美利坚合众国国防部. 程序语言Ada参考手册. Springer-Verlag,1980

[P.H82] P.Henderson. 纯函数式操作系统. 1982

[Tur81] D.A.Turner. 应用语言在语义上的优雅性. 1981年度函数式语言与计算机架构会议会报,海边温特渥,普茨茅斯,新汉普夏郡,1981

[Tur85] D.A.Turner. Miranda: 拥有多态类型的非严格语言. 1985年度函数式语言与计算机架构会议会报,1-16页,南锡,法国,1985

[Wir82] N.Wirth. Modula-II程序设计. Springer-Verlag,1982
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

Hack Primer (1) 恶魔城-晓月圆舞曲 +Special +全能力

首先我们需要得工具是:
调试器:no$gba 1.4c
模拟器+rom:vba1.2+晓月中文版
16进制编辑器:UE5.4
上面得只是我用得,当然可以用其他同样功能得工具

我改游戏有个习惯,不喜欢改得很变态.所以改得地方都是一些不影响游戏性得东西(我认为).游戏里面有个special模式.不过要打穿一次才出现.那我们首先把这个弄出来.我们首先找到关键得地址是2000060h,为3h得时候就有special模式.至于这个地址怎么得到得.建议在修改之前看看ec得作弊码,类似000060h这些就直接在前面加一个2就可以了打 开调试器.下断点[2000060h]!.中文版会在08000820h停下(其他版本我没有弄,不知道),当然这里不是.继续080006e4h又停 了,当然还是不是.最后在030021d6h停下.这里就是真正得初始化得程序了.至于为什么我知道那里是,自己多看看就明白了 不过 千万不要在这里(030021d6)改动.因为gba游戏得rom代码得起始地址为08000000h,所以只要小于这个的都不能修改.用单步调试发现这 里初始化了不只一个数据(快捷键F7是单步,这个是个很常用的键).为了不改动到其他的数据,我们干脆在初始化了以后再给02000060h赋值为3h, 这样就达到我们的目的了.
一直F7(这里有个循环,可以用run to cursor,来直接跳出循环)到这段代码返回.返回的地方代码是这样的:
080129CC 6803 ldr r3,[r0]
080129CE 4806 ldr r0,=0E001AF8h
080129D0 2204 mov r2,4h
080129D2 F0C6F9D7 bl 80D8D84h
----------------------------------------------------------------
080129D6>1C20 mov r0,r4
080129D8 BC30 pop r4,r5
080129DA BC02 pop r1 这里的几句是我们关心的
080129DC 4708 bx r1
--------------------------------------------------------------
080129DE 0000 lsl r0,r0,0h
080129E0 CA78 ldmia [r2]!,r3-r6
那个>符号就是我们下一步要执行的语句,
080129D6>1C20 mov r0,r4上面的代码肯定调用了刚才的那段程序.但是这个和我们无关.
那我们要自己写一段给02000060h赋值的语句,所以我们先找到一个空白的地方(一般都是0000)来放我们的代码,我找到的地方是813bc44h,所以我们修改下上面的代码.

把原来的代码记下来,把上面的代码改成这样:
080129CC 6803 ldr r3,[r0]
080129CE 4806 ldr r0,=0E001AF8h
080129D0 2204 mov r2,4h
080129D2 F0C6F9D7 bl 80D8D84h
------------------------------------------
080129D6>B530 push r4,r5,lr
080129D8 F129F934 bl 813BC44h
080129DC 4708 bx r1
------------------------------------------
080129DE 0000 lsl r0,r0,0h
080129E0 CA78 ldmia [r2]!,r3-r6
其实写的语句只有两句.好了.跳转语句写好了.让我们跳到813bc44h来写我们的代码吧

0813BC44>2202 mov r2,2h
0813BC46 0612 lsl r2,r2,18h
0813BC48 3260 add r2,60h
0813BC4A 2103 mov r1,3h
0813BC4C 7011 strb r1,[r2]
0813BC4E BC30 pop r4,r5 <-恢复原来的r4,r5的值
0813BC50 1C20 mov r0,r4 <---这里是我们改代码的时候覆盖的部分
0813BC52 BC30 pop r4,r5
0813BC54 BC02 pop r1
0813BC56 BD00 pop pc
上面就是我添加的代码,很简单吧

然后用*键重新启动游戏,看看会不会出错,不会的话,就用UE打开rom,修改代码吧.这里只有一点要提醒:高低位要颠倒过来.比如这里我们添加的 2202 0612 ,写的时候就要写成 0222 1206.
反正4位(16进制的)一组.

这样,我们的第一个就改好了. 改能力与这个基本相同.我只说不同的,也是关键的部分
下断点以后,选择存档的时候会中断,同样是03开头的地址,而且也是多个初始化.我们F7返回到rom的代码.
08011978 1C38 mov r0,r7
0801197A F0C7FA03 bl 80D8D84h
--------------------------------------------------------
0801197E>4917 ldr r1,=0E0001A0h
08011980 1868 add r0,r5,r1
08011982 4642 mov r2,r8
08011984 6811 ldr r1,[r2]
08011986 3194 add r1,094h
08011988 6823 ldr r3,[r4]
0801198A 2220 mov r2,20h
0801198C F12AF966 bl 80D8D84h
------------------------------------------------------
08011990 4913 ldr r1,=0E0001C0h
08011992 1868 add r0,r5,r1
这里怎么办呢?发现到这里有一个跳转指令,bl 80D8D84h,我们就干脆在这里改吧.
同样的,在813BC5Ch找到空白(Konami真好,有那么多空白的地方),
bl 80D8D84h 改为 bl 813BC5Ch
然后在813BC5Ch写我们代码

不过看跳转指令上面的代码,有很多有用的值保存在寄存器里面
所以我们干脆把r0到r7都push,保存起来.
0813BC5C>B4FF push r0-r7
0813BC5E 2303 mov r3,3h
0813BC60 21FF mov r1,0FFh
0813BC62 2202 mov r2,2h
0813BC64 0312 lsl r2,r2,0Ch
0813BC66 3213 add r2,13h
0813BC68 0212 lsl r2,r2,8h
0813BC6A 3238 add r2,38h
0813BC6C 0112 lsl r2,r2,4h
0813BC6E 3206 add r2,6h
0813BC70 7011 strb r1,[r2]
0813BC72 3201 add r2,1h
0813BC74 3B01 sub r3,1h
0813BC76 2B00 cmp r3,0h
0813BC78 D1FA bne 813BC70h
0813BC7A 3201 add r2,1h
0813BC7C 213F mov r1,3Fh
0813BC7E 7011 strb r1,[r2]
0813BC80 BCFF pop r0-r7
0813BC82 F79DF87F bl 80D8D84h <-原来要跳转的地方
0813BC86 F6D5FE83 bl 8011990h
这个就是我们添加的代码了,有人会问:和能力有关的不是只有02013386h到020133883h个地方吗?
当然是这样,不过进入游戏以后这些能里没有默认打开,所以在0201338ah,把数据改成3fh(其实就是二进制的6个1),那么就默认打开了.

不 过这里要提出关键的一点,就是最有一句bl 8011990h.这是原来跳转指令的下面一条指令.为什么在这里要加一句呢?因为在03XXXXXX返回的 时候,是按照调用的指令寄存器的值来返回,也就是说,原来的80118c0h F79DF87F bl 80D8D84h,执行这一句前的时候,指令寄存 器的地址是80118c0h,所以返回的时候就直接是8011990h(就自动返回到了他的下面一条指令).但是我们改动了代码,所以调用指令的时候寄存 器的值已经改变了.他会回到我们调用的下面一条指令的地址,就是0813BC86,所以我们要手动的让程序跳转到原来的8011990h那里.

补一句,bl和bx的差别就像是用goto语句和调用函数的差别,函数要返回到原来的下一条指令继续执行.goto就不用

好拉,就这样.不知道大家明白没有.
Have Fun.
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

Hack Primer (2) 恶魔城---晓月 +不同名字对应不同能力

1.
当然我们可以这么搜索,这个是一个很传统的做法,因为我们最后肯定是要通过一个按键来结
束名字的输入.
而我们又知道这个地址.所以我们可以追这个地址.不过传统的东西虽然通常有不错的通用型
(因为新事物往往有很大的局限性或者是传统方法的延伸),但是往往很麻烦.所以我们在这里
要用其他的方法得到人物初始化的代码和名字判断的代码.

其实我们可以发现SOMA和JULIUS的HP是不同的,而且又很容易从EC找到HP的地址是多少(我的
前一篇教程和前辈的文章里面有如何从EC的地址得到实际的内存地址),我们在这个地址上中
断,所以呢~,很容易吧

然后我们仔细看能力初始化代码的开始部分,会发现人物的标志位,所以我们同样的可以很容
易找到人物名字的判断代码.

2.
r0 读入一个输入的字符
r1 读入一个特定名字字符
r2 输入名字的地址(这个地址在程序转入之前已经赋值)
r3 特定名字的地址(初始化为08143EB0)
r4 人物标志(初始化为1)
r5 循环次数(名字最动为8个字母,所以固定为8)
r6 自定义的人物标志位(03000c60)

在这里跳转,刚好r2放有我们需要的地址
80078fc:ldrb r0,[r4]
cmp r1,r0

改为 bl 8143f40

这里的代码很简单,没有什么可以说的.要是有问题就给我Message

08143F40>B4FF push r0-r7
08143F42 4E07 ldr r6,=30007FFh
08143F44 4B07 ldr r3,=8143EB0h
08143F46 2500 mov r5,0h
08143F48 2401 mov r4,1h
08143F4A E019 b 8143F80h
08143F4C 2C03 cmp r4,3h
08143F4E D027 beq 8143FA0h
08143F50 2500 mov r5,0h
08143F52 3310 add r3,10h
08143F54 3401 add r4,1h
08143F56 E013 b 8143F80h
-----------------------------------------------
08143F60 07FF lsl r7,r7,1Fh
08143F62 0300 lsl r0,r0,0Ch
08143F64 3EB0 sub r6,0B0h
08143F66 0814 lsr r4,r2,20h
------------------------------------------------
08143F80 5D50 ldrb r0,[r2,r5]
08143F82 5D59 ldrb r1,[r3,r5]
08143F84 4288 cmp r0,r1
08143F86 D1E1 bne 8143F4Ch
08143F88 3501 add r5,1h
08143F8A 2D08 cmp r5,8h
08143F8C D1F8 bne 8143F80h
08143F8E 7034 strb r4,[r6]
08143F90 2C01 cmp r4,1h
08143F92 4D02 ldr r5,=201325Ah
08143F94 702C strb r4,[r5]
08143F96 E003 b 8143FA0h
08143F98 0000 lsl r0,r0,0h
08143F9A 0000 lsl r0,r0,0h
08143F9C 325A add r2,5Ah
08143F9E 0201 lsl r1,r0,8h
08143FA0 BCFF pop r0-r7
08143FA2 7820 ldrb r0,[r4]
08143FA4 4770 bx r14

3.
存放名字的地址:02000090h
恶魔城晓月(中文版)字母对照表
A 02 B 03 C 04 D 05
E 06 F 07 G 08 H 09
I 0A J 0B K 0C L 0D
M 0E N 0F O 10 P 11
Q 12 R 13 S 14 T 15
U 16 V 17 W 18 X 19
Y 1A Z 1B @ 1C . 1D
- 1E ' 1F ! 20 (Skull) 21
(Cross) 22

名字对应的值
JULIUS 0B160D0A 1614
DRACULA 05130204 160D02
LUCKYDOG 0D16040C 1A051008

4.
0813FF44>B500 push lr

0813FF46 493E ldr r1,=2013250h
0813FF48 2001 mov r0,1h
0813FF4A 7748 strb r0,[r1,1Dh] 这里的nop是因为发现了人物标志只能是0,1
0813FF4C 46C0 nop 所以把存放的位置改变了,放在30007FFh
0813FF4E 46C0 nop 原来的赋值语句太原始了,不便于阅读
0813FF50 46C0 nop
0813FF52 46C0 nop
0813FF54 7A88 ldrb r0,[r1,0Ah]
0813FF56 46C0 nop

0813FF58 2802 cmp r0,2h 如果名字是LUCKYDOG
0813FF5A D10E bne 813FF7Ah
0813FF5C 20A0 mov r0,0A0h
0813FF5E 0040 lsl r0,r0,1h
0813FF60 8448 strh r0,[r1,22h]
0813FF62 2050 mov r0,50h
0813FF64 8488 strh r0,[r1,24h]
0813FF66 2005 mov r0,5h
0813FF68 84C8 strh r0,[r1,26h]
0813FF6A 2006 mov r0,6h
0813FF6C 8508 strh r0,[r1,28h]
0813FF6E 2006 mov r0,6h
0813FF70 8548 strh r0,[r1,2Ah]
0813FF72 20FA mov r0,0FFh
0813FF74 0080 lsl r0,r0,2h
0813FF76 8588 strh r0,[r1,2Ch]
0813FF78 E031 b 813FFDEh

0813FF7A 2801 cmp r0,1h 如果名字是JULIUS
0813FF7C D10E bne 813FF9Ch
0813FF7E 20C8 mov r0,0C8h
0813FF80 0080 lsl r0,r0,2h
0813FF82 8448 strh r0,[r1,22h]
0813FF84 20FF mov r0,0FFh
0813FF86 0040 lsl r0,r0,1h
0813FF88 8488 strh r0,[r1,24h]
0813FF8A 200F mov r0,0Fh
0813FF8C 84C8 strh r0,[r1,26h]
0813FF8E 200C mov r0,0Ch
0813FF90 8508 strh r0,[r1,28h]
0813FF92 200A mov r0,0Ah
0813FF94 8548 strh r0,[r1,2Ah]
0813FF96 2014 mov r0,14h
0813FF98 8588 strh r0,[r1,2Ch]
0813FF9A E020 b 813FFDEh

0813FF9C 20A0 mov r0,0A0h 初始化SOMA的能力
0813FF9E 0040 lsl r0,r0,1h
0813FFA0 8448 strh r0,[r1,22h]
0813FFA2 2050 mov r0,50h
0813FFA4 8488 strh r0,[r1,24h]
0813FFA6 200A mov r0,0Ah
0813FFA8 84C8 strh r0,[r1,26h]
0813FFAA 200C mov r0,0Ch
0813FFAC 8508 strh r0,[r1,28h]
0813FFAE 200B mov r0,0Bh
0813FFB0 8548 strh r0,[r1,2Ah]
0813FFB2 2009 mov r0,9h
0813FFB4 8588 strh r0,[r1,2Ch]
0813FFB6 7A88 ldrb r0,[r1,0Ah]

0813FFB8 2803 cmp r0,3h 如果名字是DRACULA,则打开所有的能力
0813FFBA D110 bne 813FFDEh
0813FFBC 2303 mov r3,3h
0813FFBE 21FF mov r1,0FFh
0813FFC0 2002 mov r0,2h
0813FFC2 0300 lsl r0,r0,0Ch
0813FFC4 3013 add r0,13h
0813FFC6 0200 lsl r0,r0,8h
0813FFC8 3038 add r0,38h
0813FFCA 0100 lsl r0,r0,4h
0813FFCC 3006 add r0,6h
0813FFCE 7001 strb r1,[r0]
0813FFD0 3001 add r0,1h
0813FFD2 3B01 sub r3,1h
0813FFD4 2B00 cmp r3,0h
0813FFD6 D1FA bne 813FFCEh
0813FFD8 3001 add r0,1h
0813FFDA 213F mov r1,3Fh
0813FFDC 7001 strb r1,[r0]

0813FFDE 2020 mov r0,20h
0813FFE0 0200 lsl r0,r0,8h
0813FFE2 3013 add r0,13h
0813FFE4 0200 lsl r0,r0,8h
0813FFE6 3025 add r0,25h
0813FFE8 0100 lsl r0,r0,4h r0=201325Ah,r1本来是这个值,可惜被改变了.又不想
0813FFEA 300A add r0,0Ah 改变很多寄存器,所以就麻烦一点,幸好空间很多

0813FFEC 2100 mov r1,0h
0813FFEE 7803 ldrb r3,[r0]
0813FFF0 2B01 cmp r3,1h 201325A的值一定只能是0或者1,
0813FFF2 D000 beq 813FFF6h 否则会初始化图像的时候出错
0813FFF4 7001 strb r1,[r0]

0813FFF6 2B02 cmp r3,2h 如果是LUCKYDOG,送两枚戒指
0813FFF8 D103 bne 8140002h
0813FFFA 30B0 add r0,0B0h r0=201330A
0813FFFC 2101 mov r1,1h
0813FFFE 7001 strb r1,[r0]
08140000 7041 strb r1,[r0,1h]

08140002 BC01 pop r0 返回
08140004 4700 bx r0


魂出现几率上升戒指 201330A
稀有物品出现几率上升戒指 201330B

5.
在打补丁后会出现人物走动时候有些地方会间歇的花瓶,不过不影响游戏的进行.这个应该是
我们的代码覆盖到这些图形的原因.不过我也没有找到其他的位置放.

我想说的是人物的标志位的值.为什么我用了一个其他的内存地址来存放新加入的人物?
我们常这样写一个程序(我本身是作软件开发)
在一个地方设置一个判断标志,如果这个标志的值是0,那么我们跳转到一个地方执行,如果是
1我们的代码会到另外一个地方执行.但是我们往往会一厢情愿的认为我们加入一个新的值,
然后跳到我们所希望的地方.当然这是一个方法.但是具体的要按照实际情况来分析.在这里
如果我们把201325a的值设置为0,1之外的值,那么程序会出现异常.但是我们往往开始怀疑
的是我们自己添加的代码的正确性,所以我们不停的修改,但是还是不行.

Hack所需要的不仅是技术,耐心.也同样需要自信.我们要对自己的代码自信.

所以我们不如假设标志值只能是0或者1,这样我们就能解决这个问题.

我自己是一个Hack的初学者,在这里感谢Lordquest对ldr r3,=8015340h这类代码的耐心解释.
如果大家对我写的东西有任何问题可以给我message,我不定期上网.我会对我的Hack心得全部写出来
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

Hack Primer (3) 恶魔城---晓月 +SOMA使用JULIUS能力

首先我们要找到魂使用的代码位置,
我们很容易知道201325d存放的是魂(上+B)的地址,作揖我们下中断r0=201325d.
这时我们上+B的时候游戏中断.用F7执行到pop返回调用的地方
然后我们看到这样的代码(再返回位置的上面一点点)

0801B3F8 483B ldr r0,=84BCA78h
0801B3FA 6802 ldr r2,[r0]
0801B3FC 8B91 ldrh r1,[r2,1Ch] 读取按钮,组合的按钮值
0801B3FE 2040 mov r0,40h
0801B400>4008 and r0,r1
0801B402 2800 cmp r0,0h 如果不是"上+其他键",那么就r0等于0
0801B404 D010 beq 801B428h
0801B406 493F ldr r1,=1338Ch
0801B408 1850 add r0,r2,r1
0801B40A 8BD1 ldrh r1,[r2,1Eh] 读取按键的值

>>>0801B40C 8800 ldrh r0,[r0]
>>>0801B40E 4008 and r0,r1 201338c里面保存的是和上组合的另外一个键的值

0801B410 2800 cmp r0,0h
0801B412 D009 beq 801B428h
0801B414 6930 ldr r0,[r6,10h]
0801B416 21A8 mov r1,0A8h
0801B418 00C9 lsl r1,r1,3h
0801B41A 4008 and r0,r1
0801B41C 2800 cmp r0,0h
0801B41E D103 bne 801B428h

0801B420 1C30 mov r0,r6
0801B422 2100 mov r1,0h
0801B424 F7FDF8DA bl 80185DCh 我们要调用的函数

这里要说明200001c和200001e的区别.1c保存的是组合按键的值,比如"上+R"是0140h,"上+B"是42h.
不过这样不能区分这样两种方式形成的组合,即:先按住上,再按B;还是按住B再按上.
而1e里面放的值就是后按的按钮,同时按下的话值就是6A

本来想使用
"下+R"-切换附加武器
"上+R"-发动附加武器
但是既然代码这样,不如我就改成
"上+L"-切换附加武器
"上+R"-发动附加武器

把>>>改成bl 814B200
另外
80185f6 bl 802c3e8
是附加武器的函数,r0的值是入口函数,指定为特殊武器.不过遗憾的是不能直接调用这个函数,
调用了也没有结果.所以我们调用814b200的父函数,不过输入参数没有武器参数.
所以我在内存(3000000h)里面自己找一个地方存放Julius的附加武器的值
当执行上+B时,首先判断人物,如果是soma的话就把人物代码改为1(soma的值是0).
然后交换20135dh和3000000h的值,接着就调用函数嘛.函数返回再把值填回来,很简单吧

当人物为julius时,r0的值对应武器
0 回力镖
1 圣水
2 斧头
3 十字架
我们可以在执行之前设置201325A的值为1,执行了再设置为0

0814B200 B5FF push r0-r7,lr
0814B202 2002 mov r0,2h
0814B204 0202 lsl r2,r0,8h 如果是L
0814B206 400A and r2,r1
0814B208 2A00 cmp r2,0h
0814B20A D019 beq 814B240h
0814B20C 2303 mov r3,3h
0814B20E 061B lsl r3,r3,18h
0814B210 781C ldrb r4,[r3]
0814B212 2C03 cmp r4,3h
0814B214 D001 beq 814B21Ah
0814B216 3401 add r4,1h
0814B218 E000 b 814B21Ch
0814B21A 2400 mov r4,0h
0814B21C 701C strb r4,[r3]
0814B21E F000F807 bl 814B230h
0814B222 0000 lsl r0,r0,0h
0814B224 0000 lsl r0,r0,0h
0814B226 0000 lsl r0,r0,0h
0814B228 0000 lsl r0,r0,0h
0814B22A 0000 lsl r0,r0,0h
0814B22C 0000 lsl r0,r0,0h
0814B22E 0000 lsl r0,r0,0h
0814B230 BCFF pop r0-r7
0814B232 8800 ldrh r0,[r0]
0814B234 4008 and r0,r1
0814B236 BD00 pop pc
0814B238 0000 lsl r0,r0,0h
0814B23A 0000 lsl r0,r0,0h
0814B23C 0000 lsl r0,r0,0h
0814B23E 0000 lsl r0,r0,0h
0814B240 2001 mov r0,1h
0814B242 0202 lsl r2,r0,8h
0814B244 400A and r2,r1 如果是R
0814B246 2A00 cmp r2,0h
0814B248 D0F2 beq 814B230h
0814B24A 2303 mov r3,3h
0814B24C 061B lsl r3,r3,18h
0814B24E 4A04 ldr r2,=201325Ah
0814B250 7811 ldrb r1,[r2]
0814B252 2900 cmp r1,0h
0814B254 D1EC bne 814B230h
0814B256 8010 strh r0,[r2]
0814B258 78D1 ldrb r1,[r2,3h]
0814B25A 8259 strh r1,[r3,12h]
0814B25C 7819 ldrb r1,[r3]
0814B25E E004 b 814B26Ah
0814B260 325A add r2,5Ah
0814B262 0201 lsl r1,r0,8h
0814B264 0000 lsl r0,r0,0h
0814B266 0000 lsl r0,r0,0h
0814B268 0000 lsl r0,r0,0h
0814B26A 70D1 strb r1,[r2,3h]
0814B26C 1C30 mov r0,r6
0814B26E 2100 mov r1,0h
0814B270 F6CDF9B4 bl 80185DCh
0814B274 2300 mov r3,0h
0814B276 4C12 ldr r4,=201325Ah
0814B278 7023 strb r3,[r4]
0814B27A 2203 mov r2,3h
0814B27C 0612 lsl r2,r2,18h
0814B27E 46C0 nop
0814B280 7C13 ldrb r3,[r2,10h]
0814B282 70E3 strb r3,[r4,3h]
0814B284 E7D4 b 814B230h

代码很简单,不用多的说明.
不过很遗憾的是发出的都是黑色的,遗憾呐.弄了半天也没有找到怎么把图像也弄出来
不过soma用十字架的样子很好看.有一个.....自己看吧
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

Hack Primer (4) Hack的一般过程

Hack是一个很复杂的过程.从有人Hack成功后,就有人开始学习如何Hack.
我们通常所看到的是对于方法的提问,固然方法是重要的,但是对于过程我们也应该关注一下.
就像我们为了得到一个结果,可以采用不同的算法或者模式.但是面对同样的算法或者模式的时候,
不同的结果也不停的出现.这不能不说是过程在这中间起了非常重要的作用.

我也是才开始研究Hack,不能说有什么深入的了解.不过是我对Hack的一点认识.(后面一我的一个例子)

Hack的一般过程

1.Hack的工具.

动态调试器:No$Gba 1.4.这个没有过多的选择,而且这个的功能已经够用了
16进制编辑器:把调试好的程序写入rom.这种工具很多.WinHex,UE,Hex workshop
模拟器:用来测试游戏,当然调试器也可以完成,但是会有一些不方便的地方.一般都是用的VBA
补丁工具:IPS或者CodeFusion,其他的当然也可以

这些我们只要准备好就可以了.

2.确定目标

Hack不可能是随机的无目标的过程.我们不可能说用调试器来运行游戏,玩着玩着就Hack一个.
通常是这样的,我们先是正常的游戏(指大多数的人).然后有了一个Hack的想法,然后确定Hack后的目标.
所以我们可以在这里完成这些事:

2-1.要Hack的rom:这个当然有机种,rom的名字.美日的不要混用,不确定能通用的时候最好弄清楚版本
2-2.Hack后要达到的目标:比如不死,时间无限,不遇敌之类的.没有目标怎么hack呢?

3.地址搜索

这个方法很多,比如使用EC里面已经提供的.或者用模拟器自带的,或者用什么其他的东西.
因为游戏的数据都是保存在内存里面的(99.9%的情况).所以要找到我们需要的那些内存地址.
用VBA找到的地址可以在调试器里面直接用.EC的地址要通过一个简单的转换:
04XXXX的地址变换成0300XXXX
其他的直接在前面家2就可以了
然后确定不同的值对游戏的影响.

所以我们在这里要确定的就是这些:
3-1.查找到我们Hack内容相关的内存地址
3-2.确定不同的值所对游戏的影响
另外,如果没有超强的记忆力,最好把这些都记下来

4.确定关键代码

简单的我们可以直接下中断[XXXXXXX]!,让游戏在改变值的时候暂停,很多游戏都可以,
然后可以单步返回调用的地方或者直接修改代码.也可以r0=XXXXXXX之类的让寄存器
准备读取的时候来中断.这里的所采用方法要按照不同的情况来灵活运用.

这个目标比较简单,就是确定代码的位置,实际操作起来比较困难.(Hack难点)

5.确定修改方法

要达到同样的目的有很多方法,但是我们要按照不同的情况来选择,比如是赋值以后我们来修改,
还是直接拦截原来的赋值.这些都是我们要考虑的,原则就是稳定,简单,对程序改动少.
要添加自己的程序的时候,建议在开始的地方push r0-r7,lr,返回的时候pop r0-r7,
然后是原来的语句,最后用pop pc来返回

如果代码都确定了,那么这里比较的简单.不过建议初学者把寄存器表示的意义写下来.

6.初步调试,写入代码

当我们在调试器里面把代码写好以后,直接在调试器里面进行游戏.这里体现出调试器的一个好处:
如果程序有错,模拟器可能还是能够执行,但是调试器会出错.

当我们的代码运行没有错误,用16进制工具把代码代码写进rom里面

7.最后调试,制作补丁

这个是防止代码写入rom的时候出错,所以我们把代码写入以后需要最后调试一下.没有问题就作补丁吧

一般的过程就是这样的.这里我贴出我的一个例子:

恶魔城---晓月 +不同名字对应不同能力

(为了对应上面的,故把工具的编号留出.)
2-1.Rom:恶魔城---晓月(中文版)
2-2.目标:不同名字对应不同能力.
DRACULA:拥有全部能力
LUCKYDOG:幸运值最高,其他能力为一半,带有稀有物品,魂出现上升戒指
3.人物标志201325ah:0---Soma;1---Julius
名字存放的地址:02000090h
字母对照表:
恶魔城晓月(中文版)字母对照表
A 02 B 03 C 04 D 05
E 06 F 07 G 08 H 09
I 0A J 0B K 0C L 0D
M 0E N 0F O 10 P 11
Q 12 R 13 S 14 T 15
U 16 V 17 W 18 X 19
Y 1A Z 1B @ 1C . 1D
- 1E ' 1F ! 20 (Skull) 21
(Cross) 22

名字对应的值
JULIUS 0B160D0A 1614
DRACULA 05130204 160D02
LUCKYDOG 0D16040C 1A051008

魂出现几率上升戒指 201330A
稀有物品出现几率上升戒指 201330B

4.名字判断代码大智位置:80078fc
r0 读入一个输入的字符
r1 读入一个特定名字字符
r2 输入名字的地址(这个地址在程序转入之前已经赋值)
r3 特定名字的地址(初始化为08143EB0)
r4 人物标志(初始化为1)
r5 循环次数(名字最动为8个字母,所以固定为8)
r6 自定义的人物标志位(03000c60)

初始化能力代码这个笔记丢了) 

5.插入子程序.替代原来的代码.(具体代码如有兴趣请参看Hack Primer (2))
6,7(略)

这是我的一点心得,我觉得初学最好有一个步骤来Hack.如果有不对的地方请大家包含
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

Hack Primer 编外篇---简单Hack集

下载包里面有补丁和教程,下载了可以慢慢看

Hack Primer (5) 0832 The King Of Fighters EX2 Howling Blood +隐藏要素

把2010228h和229h置为FFh打开隐藏模式,拦截到这里的写入操作,
然后我选择在809c1a4h跳转(因为这时r4刚好有需要的值)

这是添加的代码

080A8122>B5FF push r0-r7,lr
080A8124 20FF mov r0,0FFh
080A8126 7020 strb r0,[r4]
080A8128 7060 strb r0,[r4,1h]
080A812A BCFF pop r0-r7
080A812C 46C0 nop
080A812E BD00 pop pc

----------------------------------------------------------------------------------------------------
Hack Primer (5) 1959 - Killer 3D Pool (E) +所有人物

从内存的3001030h到300103Fh都设置为1可以打开隐藏人物
由于没有拦截到程序写这一段内存,所以我们找一段代码来跳转.
08006A64 4684 mov r12,r0
08006A66 2784 mov r7,84h
08006A68 063F lsl r7,r7,18h
08006A6A 1AF1 sub r1,r6,r3
08006A6C 4561 cmp r1,r12
08006A6E D901 bls 8006A74h
08006A70 2180 mov r1,80h
08006A72 0109 lsl r1,r1,4h
08006A74 4A0A ldr r2,=40000D4h
08006A76 6015 str r5,[r2]
08006A78 6054 str r4,[r2,4h]
08006A7A F041FF81 bl 8048980h <<<<<这里的语句我们替换了.
08006A7E DA00 bge 8006A82h
08006A80 1CC8 add r0,r1,3
08006A82 1080 asr r0,r0,2h
08006A84 4338 orr r0,r7
08006A86 6090 str r0,[r2,8h]
08006A88>6890 ldr r0,[r2,8h]
08006A8A 185B add r3,r3,r1
08006A8C 186D add r5,r5,r1
08006A8E 1864 add r4,r4,r1
08006A90 42B3 cmp r3,r6
08006A92 D3EA bcc 8006A6Ah
08006A94 BCF0 pop r4-r7
08006A96 BC01 pop r0
08006A98 4700 bx r0

为什么要这一段呢?EC里面的数据是被锁定的,这一段代码应该是和图像显示有关的代码(我不确定).
我发现它不停的运行,所以要是我们在这里插入我们的代码就可以实现类似的锁定效果.

08048980 B5FF push r0-r7,lr
08048982 4A1F ldr r2,=3001030h
08048984 2001 mov r0,1h
08048986 2100 mov r1,0h
08048988 7010 strb r0,[r2]
0804898A 3201 add r2,1h
0804898C 3101 add r1,1h
0804898E 2910 cmp r1,10h
08048990 D1FA bne 8048988h
08048992 BCFF pop r0-r7
08048994 1C08 mov r0,r1<<<<<<<这里是我们原来替换的代码
08048996 2900 cmp r1,0h<<<<<<<
08048998 BD00 pop pc

这个时候密码随便输入吧~~反正人物都出来了
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings

日本CH小芯片1on1比赛BAD的设体图

luluken,20070313211214

简单实用,测试的时候效果好的让我吃惊。

  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • No ratings
  • 0 ratings
Pages: 1 (1 - 11 / 11)