【PL理论深化】(11) Ocaml 语言:函数式编程 vs. 命令式编程 | 递归函数的代价 | 尾递归函数(tail-recursive function)


0x00 函数式编程 vs. 命令式编程

下面是用 C 语言编写的插入排序:

void insert_sort(int arr[], int len) {
    int i, j;
    int tmp;
    for(i = 1; i < len; i++) {
        tmp = arr[i]; // 当前要插入的元素
        j = i - 1; // 已经排序部分的最后一个元素的索引

        // 将大于tmp的元素向后移动
        while(j >= 0 && arr[j] > tmp) {
            arr[j + 1] = arr[j];
            j = j - 1;
        }

        // 插入tmp到正确位置
        arr[j + 1] = tmp;
    }
}

在使用递归函数编写的情况下,代码更为简洁且可读性更高。

使用递归函数的函数式程序之所以更容易理解,

是因为它们促使我们描述问题本身而不是解决问题的步骤。

例如,重新审视前面定义的排序函数 sort 的定义,如果列表不为空,则需要排序 hd::tl ,

这意味着排序后的列表应该与排序 tl 并插入 hd 得到的列表相匹配,

即满足排序列表应具备的条件,这里描述了要实现的 sort 函数应满足以下等价关系的内容:

在 OCaml 实现中,sort 函数需要满足上述条件,这是通过递归函数来实现的。

.

另一个例子是计算阶乘的函数 factorial,当传入的参数大于 0 时,需要满足以下条件。

可以将上述规范与参数为 0 的情况一起,通过递归函数实现如下:

let rec factorial n =
    if n = 0 then 1 else n * factorial (n-1)

另一方面,命令式编程是一种引导你描述解决问题的步骤的范式。

例如,如果用 C 语言编写阶乘函数,大多数情况下将会像下面这样实现。

int factorial (int n) {
    int i; int r = 1;
    for (i = 0; i < n; i++)
      r = r * i;
    return r;
}

这段代码是满足上述阶乘函数条件的多种实现之一。

也就是说,在命令式编程中,不仅要描述问题,还要进一步提供具体的实现方法。

相比之下,函数式编程侧重于描述问题,因此也被称为 声明式编程 (declarative programming) 。

.

0x01 递归函数的代价

参考 OCaml 等函数式编程语言,递归函数相比循环语句的代价并不更昂贵。

例如,在 C 语言中调用如下函数会发生 栈溢出 (stack overflow) ,但在函数式编程语言中则不会。

void f() { f(); } /* stack overflow */

在 OCaml 中,函数调用时不会额外消耗内存,并且可以无限递归运行。

let rec f () = f () (* 死循环 *)

两种情况下,函数 f 都表示了一个无限循环,而 OCaml 能够按照其字面意义执行。

由此可见,在 OCaml 中,函数即使是递归定义的,也并不比循环语句更昂贵。

0x02 尾递归函数(tail-recursive function)

函数调用如果代价高昂,是因为该函数描述的计算本身代价高,而不是因为计算是递归描述的。

更详细地解释,for 或 while 等循环语句总是可以。

转换成 尾递归函数 (tail-recursive function) 的形式,而尾递归函数的调用并不昂贵。

尾递归函数是指调用递归函数后没有其他工作要做的函数。

例如,考虑返回列表最后一个元素的函数 last :

let rec last l =
match l with
| [a] -> a
| _::tl -> last tl (* 尾递归调用 *)

在这里,函数调用 last tl 指的是尾递归调用。

last tl 的结果计算完成后,意味着没有额外的任务需要执行。

.

因为没有额外任务依赖于该结果,所以当函数以尾递归形式调用时,

无需返回到调用点,因此不需要保存额外的内存。

许多函数式语言的编译器支持这种尾调用优化。

相反,对于像阶乘函数这样的非尾递归函数,递归调用时则需要额外的内存。

let rec factorial n =
  if n = 1 then 1 else n * factorial (n - 1)

在计算递归调用 factorial (a-1) 后,由于还需要进行乘以 a 的操作,所以这不是尾递归调用。

在这种情况下,递归调用会消耗内存,对于较大的数,可能会导致栈溢出。

需要注意的是,总是可以将不是尾递归形式的函数转换为尾递归形式。

.

这与能够用循环表达所有递归函数的原理相同。

例如,可以将 factorial 定义为以下形式的尾递归:

let rec factorial n r =
  if n = 1 then r else factorial (n - 1) (r * n)

例如,5 的阶乘可以表示为 factorial 5

📌 [ 笔者 ]   王亦优
📃 [ 更新 ]   2024.6.20
❌ [ 勘误 ]   /* 暂无 */
📜 [ 声明 ]   由于作者水平有限,本文有错误和不准确之处在所难免,
              本人也很想知道这些错误,恳望读者批评指正!

📜 参考资料 

- R. Neapolitan, Foundations of Algorithms (5th ed.), Jones & Bartlett, 2015.

- T. Cormen《算法导论》(第三版),麻省理工学院出版社,2009年。

- T. Roughgarden, Algorithms Illuminated, Part 1~3, Soundlikeyourself Publishing, 2018.

- J. Kleinberg&E. Tardos, Algorithm Design, Addison Wesley, 2005.

- R. Sedgewick&K. Wayne,《算法》(第四版),Addison-Wesley,2011

- S. Dasgupta,《算法》,McGraw-Hill教育出版社,2006。

- S. Baase&A. Van Gelder, Computer Algorithms: 设计与分析简介》,Addison Wesley,2000。

- E. Horowitz,《C语言中的数据结构基础》,计算机科学出版社,1993

- S. Skiena, The Algorithm Design Manual (2nd ed.), Springer, 2008.

- A. Aho, J. Hopcroft, and J. Ullman, Design and Analysis of Algorithms, Addison-Wesley, 1974.

- M. Weiss, Data Structure and Algorithm Analysis in C (2nd ed.), Pearson, 1997.

- A. Levitin, Introduction to the Design and Analysis of Algorithms, Addison Wesley, 2003. - A. Aho, J. Hopcroft, and J. Ullman, Data Structures and Algorithms, Addison-Wesley, 1983.

- E. Horowitz, S. Sahni and S. Rajasekaran, Computer Algorithms/C++, Computer Science Press, 1997.

- R. Sedgewick, Algorithms in C: 第1-4部分(第三版),Addison-Wesley,1998

- R. Sedgewick,《C语言中的算法》。第5部分(第3版),Addison-Wesley,2002

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

王平渊

喜欢的话可以支持下我的付费专栏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值