Clojure 学习笔记 :10 美妙的递归

Clojure 零基础 学习笔记 递归 尾递归


Clojure 学习笔记 :10 美妙的递归

递归,或者说函数的递归,在程序设计语言里指的是函数内部又调用函数本身的用法。

一个经典的例子就是阶乘的计算。我们来分析一下,数学上的阶乘是什么意思呢?简单来说,N 的阶乘就等于 1 * 2 * 3 ... * N 的值。如果不使用递归,我们可以先搞一个从 1 到 N 的数列,然后把它们相乘。
我们可以用 reduce 函数来实现:

(defn factorial
    [number]
    (reduce * (range 1 (inc number)))) ;; 注意这里 range 的第二个参数要加一。

也可以用 apply 函数实现:

(defn factorial
    [number]
    (apply * (range 1 (inc number))))

apply 函数可以把一个序列展开作为某个函数的参数:

(apply * [1 2 3])
;; 等价于
(* 1 2 3)

但是,当你试图计算 100 的阶乘的时候,就会出现问题。为什么呢?因为 Clojure 中整数 字面量[1] 默认为 int 类型,而 100 的阶乘已经大于 int 类型所能表达的最大值。为了解决这个问题,Clojure 提供了可以支持无限精度的 “大数” 字面量。你只需要在整数字面量后面加上大写的 N,就可以把它变为可支持 无限范围[2] 的 “大整数”。顺带一提,如果在小数后加上大写的 M,就可以把它变为无限精度的小数。大数类型和普通类型进行运算,结果自动变成大数类型。

(defn factorial
    [number]
    (reduce * (range 1N (inc number))))

=> (factorial 100) 
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000N

说了半天,还是没有说到本次的主题。如何使用递归来解决阶乘的问题呢?首先,我们可以观察出,N 的阶乘其实等于 N-1 的阶乘的值 再乘以 N。如果可以得出 N-1 的阶乘的值,计算出 N 的阶乘就很简单了。那 N-1 的阶乘等于多少呢?它等于 N-2 的阶乘 乘以 N-1 …… 不行太绕了。

其实仔细想一下,N-1 的阶乘的值,不也是我们这个阶乘函数的 “责任范围” 么?如果假装我们的函数已经做好了,那我们直接调用我们的阶乘函数不就好了?这么神奇么?是的,就是这么神奇。

代码看起来还是很清晰的:

(defn factorial-recursive
    [number]
    (* number (factorial-recursive (dec number)))) ;; 假装能计算 N-1 的阶乘

看起来很完美。但是,如果你真的这么做了,分分钟系统就罢工了😂。
为什么呢?其实在函数里面我们调用自己的时候,相当于又重新执行了这个函数,系统会记下来当前的情况,然后就去新世界冒险了,但是这个新世界函数里面又调用了新函数,子子孙孙无穷尽,根本停不下来了。所以到最后系统资源耗尽,就崩溃了。
怎么办呢?我们要想办法让它停下来。怎么停下来呢?我们知道,1 的阶乘的值 就是 1,计算到 1 的时候,就可以直接返回 1 这个值了:

(defn factorial-recursive
    [number]
    (if (= 1 number)
      1N
      (* number (factorial-recursive (dec number)))))

这样,当层层调用到 1 的时候,就会停下,然后层层返回,最终得到结果。
所以“递归”,最后要“归”。

我们总结一下写出一个递归的要点:

  • 找到函数内需要执行函数自己的地方
  • 找到结束条件,避免无限递归

你可能会听到许多关于递归的 “诋毁”,比如递归效率极其缓慢,递归容易引起堆栈溢出等等。其实他们说的也不算错,不过,也不算全对。只需要做一点小小的手段,递归的速度和空间占用就可以媲美非递归形式,有时候甚至比非递归形式更快更好!
这种手段叫做尾递归优化。
什么叫尾递归优化呢?首先我们要知道什么是尾递归。
从字面上看,尾递归就是在尾巴处进行递归,这个尾巴的意思指的是,函数的最后一行(也就是函数返回值的位置)。在最后一行调用自己来递归,系统本来是要记录调用的情况方便调用完毕之后能找到回来的路来执行下面的代码,但是由于我们是在最后一行进行递归,下面已经没有代码来执行了,系统也就不用保存什么记录了,也就不会占用空间。
但是我们的阶乘函数,最后一行不仅要执行递归,而且还需要乘上一个数字,系统不得不保留那个数字,以便递归返回时进行乘法,怎么才能既保留那个数字,又能让最后一行只有递归调用呢?
答案是:把必要的数据保存在参数中,以参数的形式传递给下一次递归。我们来看一下尾递归形式的阶乘函数:

(defn factorial-tail-recursive
    [number result]
    (if (= 1 number)
        result
        (factorial-recursive (dec number) (* (bigint result) number)))) ;; 使用 bigint 函数和在整数字面量后加大写 N 的效果一样,可以把一个整型变量变成大整型

=> (factorial-tail-recursive 4 1)
24N

我们来观察一下,最后一行的确只有递归调用了,但是多的这个参数是什么意思呢?其实它用来保存每一步的结果,这里我们利用了乘法的交换律,以及任一个数字乘以 1 都等于它本身这两个性质。使用这个函数的时候,第二个参数要为 1。每次递归都会把 number 乘上这个数字,其实相当于倒着乘了一遍。最后递归终止的位置,直接返回这个结果就可以了。

这样是不是就算优化完成了?很抱歉还差最后一步。我们要把尾递归函数位置改为 recur,也就是递归的英文 recursive 的缩写。

(defn factorial-tail-recursive
    [number result]
    (if (= 1 number)
      result
      (recur (dec number) (* (bigint result) number))))

这样,一个满血版本,不会造成堆栈溢出,无限精度的,阶乘递归函数就完成了。

为什么要搞一个 recur 出来呢?Clojure 并没有强制要求 recur 必须放在末尾,如果你把 recur 放在其它地方,它会立即递归,而且递归完毕之后不会继续执行下面的代码。这样就提供了更多的灵活性。


递归在函数世界里面非常常见,它不仅表达清晰,代码简练,而且利用尾递归优化,可以让递归形式也拥有很高的效率。但尾递归要求你能用一个合理的手段把信息以参数的形式传递给下次递归,这就增加了编写递归的难度。如果你没有超凡的智力,也没有扎实的数学基础,那就多看例子,多写代码。代码量提高,才有充足的经验供你写出漂亮的递归来。


参考博客:
zx-dennis《详解Clojure的递归(上)—— 直接递归及优化》
zx-dennis《Clojure的recur尾递归优化探秘》

p.s.: 本文开头的引用也是一个 “递归” 引用。(灵感来自 什么是递归?)


  1. 字面量:直接“写死”在代码中的值。Clojure 提供了许多数据类型的字面量,如 整数,字符串,集合等。

  2. 事实上表示范围受限于你的内存大小。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容