Scala 语言: 使用递归的方式去思考

Scala 是一种有趣的语言。它一方面吸收继承了多种语言中的优秀特性,一方面又没有抛弃 Java 这个强大的平台,它运行在 Java 虚拟机(Java Virtual Machine)之上,轻松实现和丰富的 Java 类库互联互通。它既支持面向对象的编程方式,又支持函数式编程。它写出的程序像动态语言一样简洁,但事实上它确是严格意义上的静态语言。Scala 就像一位武林中的集大成者,将过去几十年计算机语言发展历史中的精萃集于一身,化繁为简,为程序员们提供了一种新的选择。作者希望通过这个系列,可以为大家介绍 Scala 语言的特性,和 Scala 语言给我们带来的关于编程思想的新的思考。

为什么递归会受到忽视
为了回答这一问题,必须先说到编程范式。在所有的编程范式中,面向对象编程(Object-Oriented Programming)无疑是最大的赢家。看看网上的招聘启事,无一例外,会要求应聘者熟练掌握面向对象编程。但其实面向对象编程并不是一种严格意义上的编程范式,严格意义上的编程范式分为:命令式编程(Imperative Programming)、函数式编程(Functional Programming)和逻辑式编程(Logic Programming)。面向对象编程只是上述几种范式的一个交叉产物,更多的还是继承了命令式编程的基因。遗憾的是,在长期的教学过程中,只有命令式编程得到了强调,那就是程序员要告诉计算机应该怎么做,而不是告诉计算机做什么。而递归则通过灵巧的函数定义,告诉计算机做什么。因此在使用命令式编程思维的程序中,不得不说,这是现在多数程序采用的编程方式,递归出镜的几率很少,而在函数式编程中,大家可以随处见到递归的方式。下面,我们就通过实例,为大家展示递归如何作为一种普遍方式,来解决编程问题

一组简单的例子
如何为一组整数数列求和?按照通常命令式编程的思维,我们会采用循环,依次遍历列表中的每个元素进行累加,最终给出求和结果。这样的程序不难写,稍微具备一点编程经验的人在一分钟之内就能写出来。这次我们换个思维,如何用递归的方式求和?为此,我们不妨把问题简化一点,假设数列包含 N 个数,如果我们已经知道了后续 N – 1 个数的和,那么整个数列的和即为第一个数加上后续 N – 1 个数的和,依此类推,我们可以以同样的方式为 N – 1 个数继续求和,直到数列为空,显然,空数列的和为零。听起来复杂,事实上我们可以用一句话来总结:一个数列的和即为数列中的第一个数加上由后续数字组成的数列的和。现在,让我们用 Scala 语言把这个想法表达出来。
清单 1. 数列求和
//xs.head 返回列表里的头元素,即第一个元素
//xs.tail 返回除头元素外的剩余元素组成的列表
def sum(xs: List[Int]): Int =
if (xs.isEmpty) 0 else xs.head + sum(xs.tail)

大家可以看到,我们只使用一行程序,就将上面求和的方法表达出来了,而且这一行程序看上去简单易懂。尽量少写代码,这也是 Scala 语言的设计哲学之一,较少的代码量意味着写起来更加容易,读起来更加易懂,同时代码出错的概率也会降低。同样的程序,使用 Scala 语言写出的代码量通常会比 Java 少一半甚至更多。
上述这个数列求和的例子并不是特别的,它代表了递归对于列表的一种普遍的处理方式,即对一个列表的操作,可转化为对第一个元素,及剩余列表的相同操作。比如我们可以用同样的方式求一个数列中的最大值。我们假设已经知道了除第一个元素外剩余数列的最大值,那么整个数列的最大值即为第一个元素和剩余数列最大值中的大者。这里需要注意的是对于一个空数列求最大值是没有意义的,所以我们需要向外抛出一个异常。当数列只包含一个元素时,最大值就为这个元素本身,这种情况是我们这个递归的边界条件。一个递归算法,必须要有这样一个边界条件,否则会一直递归下去,形成死循环。
清单 2. 求最大值
def max(xs: List[Int]): Int = {
if (xs.isEmpty)
throw new java.util.NoSuchElementException
if (xs.size == 1)
xs.head
else
if (xs.head > max(xs.tail)) xs.head else max(xs.tail)
}
同样的方式,我们也可以求一个数列中的最小值,作为一个练习,读者可下去自行实现。
让我们再看一个例子:如何反转一个字符串?比如给定一个字符串"abcd"
,经过反转之后变为"dcba"
。同样的,我们可以做一个大胆的假设,假设后续字符串已经反转过来,那么接上第一个字符,整个字符串就反转过来了。对于一个只有一个字符的字符串,不需要反转,这是我们这个递归算法的边界条件。程序实现如下:
清单 3. 反转字符串
def reverse(xs: String): String =
if (xs.length == 1) xs else reverse(xs.tail) + xs.head

最后一个例子是经典的快速排序,读者可能会觉得这个例子算不上简单,但是我们会看到,使用递归的方式,再加上 Scala 简洁的语言特性,我们只需要短短几行程序,就可以实现快速排序算法。快速排序算法的核心思想是:在一个无序列表中选择一个值,根据该值将列表分为两部分,比该值小的那一部分排在前面,比该值大的部分排在后面。对于这两部分各自使用同样的方式进行排序,直到他们为空,显然,我们认为一个空的列表即为一个排好序的列表,这就是这个算法中的边界条件。为了方便起见,我们选择第一个元素作为将列表分为两部分的值。程序实现如下:
清单 4. 快速排序
def quickSort(xs: List[Int]): List[Int] = {
if (xs.isEmpty) xs
else
quickSort(xs.filter(x=>x<xs.head)):::xs.head::quickSort(xs.filter(x=>x>xs.head))
}
当然,为了使程序更加简洁,作者在这里使用了列表中的一些方法:给列表增加一个元素,连接两个列表以及过滤一个列表,并在其中使用了 lambda 表达式。但这一切都使程序变得更符合算法的核心思想,更加易读。

尾递归
从上面的例子中我们可以看到,使用递归方式写出的程序通常通俗易懂,这其实代表这两种编程范式的不同,命令式编程范式倾向于使用循环,告诉计算机怎么做,而函数式编程范式则使用递归,告诉计算机做什么。习惯于命令式编程范式的程序员还有一个担忧:相比循环,递归不是存在效率问题吗?每一次递归调用,都会分配一个新的函数栈,如果递归嵌套很深,容易出现栈溢出的问题。比如下面计算阶乘的递归程序:
清单 5. 递归求阶乘
def factorial(n: Int): Int =
if (n == 0) 1 else n * factorial(n - 1)

当递归调用 n – 1
的阶乘时,由于需要保存前面的 n
,必须分配一个新的函数栈,这样当 n
很大时,函数栈将很快被耗尽。然而尾递归能帮我们解决这个问题,所谓尾递归是指在函数调用的最后一步,只调用该递归函数本身,此时,由于无需记住其他变量,当前的函数栈可以被重复使用。上面的程序只需稍微改造一下,既可以变成尾递归式的程序,在效率上,和循环是等价的。
清单 6. 尾递归求阶乘
def factorial(n: Int): Int = {
@tailrec
def loop(acc: Int, n: Int): Int =
if (n == 0) acc else loop(n * acc, n - 1)

loop(1, n) 

}

在上面的程序中,我们在阶乘函数内部定义了一个新的递归函数,该函数最后一步要么返回结果,要么调用该递归函数本身,所以这是一个尾递归函数。该函数多出一个变量 acc
,每次递归调用都会更新该变量,直到递归边界条件满足时返回该值,即为最后的计算结果。这是一种通用的将非尾递归函数转化为尾递归函数的方法,大家可多加练习,掌握这一方法。对于尾递归,Scala 语言特别增加了一个注释 @tailrec
,该注释可以确保程序员写出的程序是正确的尾递归程序,如果由于疏忽大意,写出的不是一个尾递归程序,则编译器会报告一个编译错误,提醒程序员修改自己的代码。

一道面试题
也许有的读者看了上面的例子后,还是感到不能信服:虽然使用递归会让程序变得简洁易懂,但我用循环也一样可以实现,大不了多几行代码而已,而且我还不用知道什么尾递归,写出的程序就是效率最高的。那我们一起来看看下面这个问题:有趣的零钱兑换问题。题目大致如下:假设某国的货币有若干面值,现给一张大面值的货币要兑换成零钱,问有多少种兑换方式。这个问题经常被各大公司作为一道面试题,不知难倒了多少同学,下面我给出该问题的递归解法,读者们可以试试该问题的非递归解法,看看从程序的易读性,及代码数量上,两者会有多大差别。该问题的递归解法思路很简单:首先确定边界条件,如果要兑换的钱数为 0,那么返回 1,即只有一种兑换方法:没法兑换。这里要注意的是该问题计算所有的兑换方法,无法兑换也算一种方法。如果零钱种类为 0 或钱数小于 0,没有任何方式进行兑换,返回 0。我们可以把找零的方法分为两类:使用不包含第一枚硬币(零钱)所有的零钱进行找零,使用包含第一枚硬币(零钱)的所有零钱进行找零,两者之和即为所有的找零方式。第一种找零方式总共有countChange(money, coins.tail)
种,第二种找零方式等价为对于 money – conins.head
进行同样的兑换,则这种兑换方式有 countChange(money - coins.head, coins)
种,两者之和即为所有的零钱兑换方式。
清单 7. 零钱兑换问题的递归解法
def countChange(money: Int, coins: List[Int]): Int = {
if (money == 0)
1
else if (coins.size == 0 || money < 0)
0
else
countChange(money, coins.tail) + countChange(money - coins.head, coins)
}

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

推荐阅读更多精彩内容