欣乐

The eagles are coming!

导航

三、函数与递归

 


三、函数与递归                                  返回目录页

1、函数的嵌套调用
2、自定义函数
3、辅助函数
4、匿名函数
5、单行函数
6、递归


引用一个MMA粉丝一段话:
Mathematica支持很多的编程范式(有可能是最多的),其中最为高效的应该就是函数式了,熟悉一点函数式语言的人再来接触Mathematica可能会倍感亲切。通过纯函数(相当于Lambda演算)、高阶函数(Nest、Fold、Map、Apply等等)等各种函数式编程的技巧,你可以轻易写出简洁到爆的程序,而且绝大部分情况下都比过程式版本高效得多。
( 来源于知乎:https://www.zhihu.com/question/20324243

习惯于过程式编程的人,往往很不习惯这种函数式编程。开始的时候,这种反应是正常的。
我们学习函数式编程,主要是因为可以开阔自己的视野,给自己一个看问题的不同的视角。


-------------------------------------------------------------------------
1、函数的嵌套调用
几个函数的依次作用,被称作嵌套函数调用(nested function call)。
因为函数的返回值,可以作为另一函数参数来用,所以函数可以嵌套调用,一层又一层。
我们学习编程,一般以阅读人家的程序开始。
开始来读懂这个嵌套函数:

Plus[Power[x,3],Power[Plus[1,x],2]]

----------------------------------------
树形结构。
在MMA中,可以将任何一个表达式看作一个树。
函数作为表达式,也是一个树。
树的呈现方式有很多。一般常用的,有两种。

第一种,当然是画出树的图形。用TreeForm函数,很容易画出。
TreeForm[x^3 + (1 + x)^2]
得:一个树形图。

第二种,以一种缩进的方式表达树。
one
    two
        twoL
        twoR
    three
        tL
            L
            R
        tR
根节点是one,根节点下有两个分支:two和three
two节点下,有两个分支:twoL和twoR
three节点下,有两个分支:tL和tR
tL节点下,有两个分支:L和R

那么,上面以第一种方式得到的树形图,可以用第二种方式来表示:
Plus
    Power
        x
        3
    Power
        Plus
            1
            x
        2
标准计算过程采用深度优先方式遍历表达式树。
如果没有学过数据结构,这话听起来有点玄。没有关系,很好理解:
嵌套函数在计算时,从最内层函数开始,一层层向外层函数进行。
那啥叫内层、啥叫外层呢?

----------------------------------------
代码风格。
在MMA中,很多嵌套函数是一行表示的。比如:
Plus[Power[x,3],Power[Plus[1,x],2]]
很多时候,换行会使代码更易读:

Plus[
  Power[
    x,
    3
  ],
  Power[
    Plus[
      1,
      x
    ],
    2
  ]
]
这种以分别两个空格缩进的方式,使代码与树形结构的第二种表达方式很相似了。
如果把[]与逗号去掉,就一模一样了。
所谓内层,指靠近树叶位置的。所谓外层,指靠近树根位置的。
内层先算,得到结果给外层。算到树根,计算结束。
(这不是就是递归运算么?是啊,MMA中大量使用递归运算。)

一个坑爹问题。
上面的缩进代码,Copy到MMA的笔记本中去时,缩进自动完全消失。变成一行了。
一直在找一个合适的MMA代码编辑器,一直没找到。

一个好消息。
当在笔记本中鼠标点击代码的不同部分时,外层函数头与[]会自动着色。
这个对阅读代码很有好处。着色的颜色,可以在菜单 (编辑/偏好设置) 中设定。

MMA自带的编辑器(即笔记本),有自动完成功能,有自动缩进功能,还不算太差,先这么用着吧。

----------------------------------------
分清楚内层外层是第一步。
然后呢,就是按步构造法,就是搞清楚每一步的函数的基本功能。
参数有几个呀、函数的实现功能是啥呀。一步步进行,最后就完全懂了。

Plus[Power[x,3],Power[Plus[1,x],2]]

Plus干啥的?参数有几个?
嗯,如果碰到不太熟悉的函数,鼠标在函数中间任意点点击一下,按“F1”。

这没啥高深的道理,这是一种技能,越用越熟。

----------------------------------------
我们来看一个纸牌程序。先创建,再洗牌。

la = Join[Range[2, 10], {J, Q, K, A}] (*得到十三张牌,没花色的*)
得:{2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A}
这里的la,是值的名称,是个符号,是个绰号,是个别名(nickname)。以后不管它出现在什么地方,都将被这个值本身所取代。
就是说,后面老是用长长一串:{2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A},麻烦不?
直接用la来代表就可以了。
用完之后,可以把一个值从这个名称上去除掉,两种方法中选取任一种均可:
Clear[la]
la=.

lb = Outer[List, {c, d, h, s}, la]
最后的la,就是{2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A},没有任何区别。
但代码好读了呀。
从得到的结果来看,这个表的层数太多了。
我们希望得到的,是这样一个表:{ {c,2},{d,A},... },作为52张牌的数据。
任何一张牌,都可以这样表示:{a,b}。a指花色,b指牌的大小。

层数太多,用Flatten函数来压平好了,指定层数为1:
Flatten[lb, 1]
52张牌的数据就这么愉快地得到了。

我们把la、lb去掉,都用本身,不用绰号,那么就变成了一句:
lc = Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1]
当然,输出结果是一样的。
但这个嵌套函数有点长,不易读。
这里我们学到了解读嵌套函数的又一招:用绰号。

Flatten[Transpose[Partition[lc, 26]], 1]
(*Partition是把52张牌一分为二。Tran...是进行转置,即化列为行。最后压平。洗牌完毕*)

如果我们不用绰号,整个洗牌程序就是这么长长的一串:
Flatten[Transpose[Partition[Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1], 26]], 1]

你可能会觉得,这个洗牌程序洗出来的牌,太有规律了,不够乱。不急,这副牌会跟随我们很长时间,以后还会不断玩牌。

从这一节,我们看到,函数式编程的第一大特点:啥都是函数,函数套函数,层层叠叠。
不过,我们已经掌握了几种解读嵌套函数的技巧。


-------------------------------------------------------------------------
2、自定义函数
MMA中的内置函数虽然很多,但用户的需求是无穷多的,很多时候必须自定义(user-defined)函数。
比如发牌程序,就不是内置函数。
自定义函数的一般格式:

name[arg1,arg2,...,argn] := body

依次为函数名(function name),函数参数(gargument),函数主体(body)
特别的一点是,函数参数必须以下划线(blank)结尾,比如:x_

因为内置函数以大写字母开头,所以一般我们取自定义函数名的时候,就不以大写字母开头了。

square[x_] := x^2
square[3]

函数经过自定义,就可以像内置函数一样使用了。

----------------------------------------
准备发牌。

cardDeck = Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
removeRand[lis_] := Delete[lis, RandomInteger[{1, Length[lis]}]]
第一行不用解释了。第二行,自定义了一个函数,函数功能是从一个表中,随机删除一个元素。
RandomInteger[{1, Length[lis]}]
产生一个1到表长度中的一个随机整数。

cardDeck = Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
removeRand[lis_] := Delete[lis, RandomInteger[{1, Length[lis]}]];
removeRand[cardDeck]
调用函数后,就有了一个表的输出。能够发现少了哪张牌么?

真的去数了么?哈哈,恭喜你,上当了。
一般玩程序的,能用程序解决的,不会去干手工。

Complement[cardDeck, %]
加一句,少的那张牌就出来了。
奇怪啊,已经删除了,怎么出来的?
Complement有取补集的功能。把cardDeck看成全集,把%中部分的51张牌,看成是一个子集,那么补集就是删除的那张牌了。

发n张牌,就这样写:
deal[n_] := Complement[cardDeck, Nest[removeRand, cardDeck, n]]
其中,Nest[removeRand, cardDeck, n] 的意思是,不断在剩余的牌中随机删除一张,直到删除了n张。

发5张牌全部程序就是:
cardDeck = Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
removeRand[lis_] := Delete[lis, RandomInteger[{1, Length[lis]}]];
deal[n_] := Complement[cardDeck, Nest[removeRand, cardDeck, n]];
deal[5]

Map[deal, Table[5, {4}]] // MatrixForm
这样就给四个人发了五张牌

Map[deal, Table[2, {6}]]
deal[5]
那就是六个人在玩德州梭哈,每个人发两张牌。然后,在中间发五张牌。

发现木有?我们在这章到现在使用的内置函数,全部都是在前一章表操作函数中学过的。


-------------------------------------------------------------------------
3、辅助函数
辅助(auxiliary)函数,可以理解为自定义函数嵌套,即自定义函数的函数体内,还有自定义函数。
分两种格式:复合函数(compound function)Module

复合函数的基本格式:
name[arg1,arg2...,argn] := (expr1; expr2; ... ; exprm)
函数体在()中,只有最后一个表达式exprm有输出。

把前面的发牌程序改一下,就成这样:

Clear[deal, cardDeck, removeRnad];
deal[n_] := (
  cardDeck = Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
  removeRand[lis_] := Delete[lis, RandomInteger[{1, Length[lis]}]];
  Complement[cardDeck, Nest[removeRand, cardDeck, n]]
)
deal[5]

程序可读性增加了。但是,cardDeck之类的名称,还是全局可见的,所以这种格式不常用。
而以下的Module格式,把cardDeck之类的名称,作为局部的,全局不可见,所以就比较常用了。

name[arg1_,...] := Module[{name1,name2=value,...}, expr]
Module中的第一个参数,是个表。表中就是我们想要把它们的名称局部化的表达式。

Clear[deal, cardDeck, removeRnad];
deal[n_] :=
 Module[{removeRand, cardDeck},
  cardDeck =
   Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
  removeRand[lis_] := Delete[lis, RandomInteger[{1, Length[lis]}]];
  Complement[cardDeck, Nest[removeRand, cardDeck, n]]]
deal[5]

把名称,通过Module格式局部化,经常是个好主意。
局部化名称的颜色,设置得显眼一点,也是个好主意。在菜单  编辑/偏好设置  中设置。


-------------------------------------------------------------------------
4、匿名函数
匿名函数(anonymous function)的名称可多了,无名函数,纯函数(连名字也没了,确实比较纯:))
匿名函数的特点是,它没有函数名。
一般我们说调用函数,先写上函数名。匿名函数没有函数名,所以是当场使用,一次性。
定义匿名函数,有两种方式。

第一种是使用内置函数Function:
Function[{x,y,...}, body]

Function[x,x^2] [3]
得9
创建函数后,马上用掉,一次性。对于以前反复使用大茶缸的,现在使用一次性茶杯,不习惯是正常的。
用多了就习惯性了。如果想不一次性,给匿名函数起个名,那也是可以的:
square = Function[x, x^2];
square[3]
得9

第二种是使用语法糖:
(#1,#2...)&
反正看到这种模样:(...)& ,就要想到,这是个无名函数。记住:()&,这三个字符,是个整体。
当参数只有一个时,可以使用#。当然了,使用#1也是可以的。
(#^2)&[3]
(#1^2)&[3]
这两句是等效的。
以这种定义方式,给无名函数起个名,也一样是可以的:
square = (#^2)&;
square[3]
一些简单的自定义函数,通过这种方式定义,还是简洁可行的。不过一般不这样做。
无名函数的主要功能是一次性。

无名函数作为函数,当然也可以嵌套使用。
(Map[(#^2)&, #])& [{1,2,3}]
表达式的运算过程,是从内层到外层。但解读程序时,很多时候从外层到内层,能抓住整体性。
(Map[(#^2)&, #])& 这是一个函数。
[{1,2,3}] 这是函数参数。

(Map[(#^2)&, #])& 这个函数,把()&部分剥离,得:
Map[(#^2)&, #]
逐渐清晰,这是个Map函数,功能是把某函数((#^2)&)分别作用于某表(#)。
注意啊,以上两个#,所代表的含义完全不同。
第一个#,是函数(#^2)&的参数。第二个#,是函数Map的参数。

使用第一种方式来写,会不会清楚点呢?
Function[y,Map[Function[x,x^2],y]] [{1,2,3}]

写一起,比比看:
(Map[(#^2)&, #])& [{1,2,3}]
Function[y,Map[Function[x,x^2],y]] [{1,2,3}]

----------------------------------------
用无名函数来发牌。

以前的自定义函数:
Clear[deal, cardDeck, removeRnad];
deal[n_] :=
 Module[{removeRand, cardDeck},
  cardDeck =
   Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
  removeRand[lis_] := Delete[lis, RandomInteger[{1, Length[lis]}]];
  Complement[cardDeck, Nest[removeRand, cardDeck, n]]]
deal[5]

改写成匿名函数:(*无非是去掉个函数名:removeRand*)
Clear[deal, cardDeck, removeRnad];
deal[n_] :=
 Module[{cardDeck},
  cardDeck =
   Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
   Complement[cardDeck, Nest[(Delete[#, RandomInteger[{1, Length[#]}]])&, cardDeck, n]]]
deal[5]
个人感觉,这种改写,意义不大。

从一个表中,随机不重复地选择几个元素出来,这程序比较有实用性:
chooseWithoutReplacement[lis_,n_] :=
  Complement[lis, Nest[(Delete[#, RandomInteger[{1, Length[#]}]])&, lis, n]];
chooseWithoutReplacement[Range[10],5]

上面的程序,已经没有必要用Module了,因为自定义的名称木有了,只有一些无名函数、内置函数等等。
具有这种形式的函数,称为单行函数(one-liner)

这一节,以做出一个完全二叉树作为结尾。
Nest[{#, #} &, x, 3] // TreeForm


-------------------------------------------------------------------------
5、单行函数
单行函数,可以理解为无名函数的一个运用。
一个自定义函数一个语句解决。

关于约瑟夫问题,是指n个人围成圈,从第一个开始绕圈数1到m(这里m为2,即间隔数)。
不断数,每次数到m的人出局,一直到剩下最后一个人。

这里用表及表处理,作了模拟(让整个圈子的人转动,这点有点新意)。还给出了过程:
survivor[lis_] :=
 Nest[(Rest[RotateLeft[#]]) &, lis, Length[lis] - 1]
(*RotateLeft是向左转圈。Rest是把表中第一个元素去掉。Nest是不断重复,结果是参数。最后一个参数是重复次数*)
survivor[Range[10]] (*调用函数*)
TracePrint[survivor[Range[10]], RotateLeft]
(*第二个参数,是TracePrint的参数。只跟踪RotataLeft相关的数据。。*)


-------------------------------------------------------------------------
6、递归
递归是这样一种函数:在自定义函数时,函数体内用到函数名本身。
递归在MMA内部大量使用,因为表达式的内部存放形式是树形,而递归是遍历树形的最通常的方式。
树的层数不多时,递归的效率是高的。反之递归的效率极低。

我们从斐波那契数(Fibonacci number)开始。
Fib是指这样一种数列:从0、1开始,后面的所有项,都是前两项之和。

f[n_] := f[n - 2] + f[n - 1];
f[6]

运行这个程序,可以看到警告:超过1024的递归深度。
因为没有递归基。
在递归的过程中,函数不断调用自己,总要碰到一个不需要递归便可计算出来的值,作为返回。否则就一直调用自己,停止不下来了。这个可确定计算出来的值,称为递归基。在Fib中,递归基是开始的两个数,0和1。

f[0]=0;
f[1]=1;
f[n_] := f[n - 2] + f[n - 1];
f[6]
确定递归基后,程序能正常运行了。
这个程序的内部结构是个二叉树,存在大量的重复计算,效率是极低的。

可以考虑用一种叫动态程序设计的方法,把中间结果保存下来。
f[0] := 0
f[1] := 1
f[n_] := f[n] = f[n - 2] + f[n - 1]
f[2000]

(*
其实啊,这只是举例。真正要提高算Fib的效率,直接迭代效率最高。
fib[n_] :=
 Module[{a = 0, b = 1, c = 1, i = 2},
  While[i < n, a = b; b = c; c = a + b; i++];
  c]
fib[5]
把5改成50000试试?一样很快。
*)

----------------------------------------
很多表处理函数,也可以用递归实现。

比如:
length[lis_] := length[Rest[lis]] + 1
length[{}] := 0
length[{1, 2, 3}]
这里写成length,以示与内置函数Length的区别。

----------------------------------------
我们把发牌程序,写成递归形式。

原来的:
deal[n_] :=
 Module[{removeRand, cardDeck},
  cardDeck = Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
  removeRand[lis_] := Delete[lis, RandomInteger[{1, Length[lis]}]];
  Complement[cardDeck, Nest[removeRand, cardDeck, n]]]
deal[5]

递归的:
cardDeck = Flatten[Outer[List, {c, d, h, s}, Join[Range[2, 10], {J, Q, K, A}]], 1];
deal[0] := {}
deal[n_] := Module[{dealt = deal[n - 1]},
  Append[dealt, Complement[cardDeck, dealt] [[RandomInteger[{1, 53 - n}]]]]]
deal[5]

递归基是空表,因为啥牌也没有发。
很多时候啊,我们不必知道递归细节。但要知道思路。
一般的设计递归程序的思路是,假设已经完成了n-1步,那么第n步怎么办?

这里,我们假设已经发了n-1张牌。
dealt是个局部名称,记录了所发的n-1张牌的表。
在剩下的牌中,随机取一张,添加到n-1张牌中去,那么n张牌就发好了。
Complement[cardDeck, dealt]  :剩下的牌
[[RandomInteger[{1, 53 - n}]]]  :随机取一张。
牌共有52张,已经发掉了n-1张,所以剩下张数为:52-(n-1)=53-n。


----------------------------------------
最后,我们再来看一个递归程序,来结束本节、本章。

二叉树的基本单元,根节点记为“one”,左节点记为“oneL”,右节点记为“oneR”
那么我们用表来表示是:
{"one",{"oneL","oneR"}}
如果左右节点均有分支,那么表就不断嵌套。

我们来编制一个递归程序,来遍历这个二叉树表,而输出以缩进格式表示树结构,比如:
one
    two
        twoL
        twoR
    three
        tL
            L
            R
        tR

程序为:
----------------------------------------
printTree[t_] := printTree[t, 0] (*输出空,相当于没输出*)
printTree[{lab_}, k_] := printIndented[lab, 4 k] (*输出几个空格、内容*)
printIndented[x_, spaces_] :=
 Print[Apply[StringJoin, Table[" ", {spaces}]], x]

printTree[{lab_, lc_, rc_}, k_] :=
 (
  printIndented[lab, 4 k]; (*输出几个空格、内容*)
  Map[(printTree[#, k + 1]) &, {lc, rc}];  (*递归,遍历子树。这个程序只能处理二叉树*)
  )

printTree[{"one", {"two", {"twoL"}, {"twoR"}}, {"three", {"tL", {"L"}, {"R"}}, {"tR"}}}]
----------------------------------------

printTree[t_] := printTree[t, 0] (*输出空,相当于没输出*)
printTree[{lab_}, k_] := printIndented[lab, 4 k] (*输出几个空格、内容*)
printIndented[x_, spaces_] := 
 Print[Apply[StringJoin, Table[" ", {spaces}]], x] 

printTree[{lab_, lc_, rc_}, k_] :=
 (
  printIndented[lab, 4 k]; (*输出几个空格、内容*)
  Map[(printTree[#, k + 1]) &, {lc, rc}];  (*递归,遍历子树。这个程序只能处理二叉树*)
  ) 

printTree[{"one", {"two", {"twoL"}, {"twoR"}}, {"three", {"tL", {"L"}, {"R"}}, {"tR"}}}]
View Code


总的思路是,把二叉树图形,转化为表,再用自定义函数,把表转化为缩进格式的文本。

TreeForm[x^3 + (1 + x)^2]
这个程序产生的二叉树图,转化为表:
lis={"Plus", {"Power", {"x"}, {"3"}}, {"Power", {"Plus", {"1"}, {"x"}}, {"2"}}}
printTree[lis]
调用函数,得:

Plus
    Power
        x
        3
    Power
        Plus
            1
            x
        2

从而我们看到了二叉树的不同表达形式。

 

 

++++++++++++++++++++++++++++++++++++++++++

扩展阅读:《计算机程序的构造和解释(SICP)》,传说中的MIT教程,函数式编程必读书。
豆瓣上的评价(链接)
这本书的中文版翻译得很好(作者叫裘宗燕,听起来是个女士,实际上是个大男人:))。这本书中,用的是Scheme语言,不是伪代码。下载一个PLT Scheme,就可以玩Lisp方言Scheme了。(这本书的中文版的pdf,及玩PLT Scheme的安装程序,均在目录页的百度云中提供下载。)
知乎上的讨论(链接)







                    Top










 

posted on 2016-10-24 11:52  欣乐  阅读(789)  评论(0编辑  收藏  举报