递归法的理解——以反转链表为例
2020-01-07
递归是什么:
递归,从定义上说,指的是某个函数直接或者间接调用自己时,则发生了递归。
比如说著名的斐波拉契数列的实现方法之一:
public static int f(int n){ if(n == 1 || n == 2) return 1; return f(n-1) + f(n-2); }
在这个例子中,对于n大于2的情况,我们都直接调用f自身来递归解决了这个问题。
从底层的情况来思考,实际上计算机将相关的函数先压入stack中,然后再pop出来,由此要使用额外的空间与时间,所以当相关的算法设计的不够精巧时,可能会带来额外的开支。
这个算法的数学本质其实并不神秘,就是普通的数学归纳法而已:为了解决问题p(n),首先解决基础情况p(1),然后假定p(n-1)已被完成,在此条件下,若能解出p(n),那么这一问题可解。(我们中学学习时,往往是用于证明一些问题,这里需要把它迁移到编程解决一个特定问题,略有区别)
从其数学本质上看似乎不难,但是,实际编程时,递归的思考实际上是“违反直觉的”(英文就是counter-intuitive),想一步步理解清楚递归函数究竟做了什么,即使对于很有经验的程序员来说,也是很困难的。这也是递归类问题一直给初学者带来困扰的主要原因。
如果姿势水平不够,那就得再学习一个。参考[1]的说法,理解递归时,只需要“明白每个函数能做的事,并相信它们能够完成”就可以了,拆解也是一件较为模块化的事情,理解只需要达到“这个部分能完成xx功能”即可,过度的拆解实际上不利于编程。
基于这样的思想,我们可以引入所谓“递归三要素”来思考递归相关的问题。[2](在九章算法的相关课程中第一回见到这个说法,至于课程本身,大家见仁见智吧)
递归三要素:
递归的定义:递归函数接受什么参数、返回什么值、代表什么意思。当函数直接或间接调用自己时,也就发生了递归。
简而言之,就是由于在编程过程中,会重复地运用这个函数,所以这个函数的可复用性应当会很强,一般而言要从问题中抽象出较为通用的求解范式。
递归的拆解:每次的递归都要让问题的规模变小。
比如说,在斐波拉契数列问题中,我们每一步至少向前两步逼近一些问题;在分治法相关的递归设计中,往往可以将问题分解为左右两个对称的部分,先拆解,再综合结果进行比较或其他运算。
递归的出口:递归必须有一个明确的结束条件。
如果说前面都是在“递”,那么这一步就是确定何时“归”。如果久递不归,那就颇有点“浊酒一杯家万里,燕然未勒归无计”的味道了,对计算机而言,最后的结果自然是内存溢出。
在编程时,需要注意给定一个限制条件让函数return值。
LeetCode 206 Reverse Linked List:
题目不难理解,就是将一个线性链表反转里面的数据,如果原本是1->2->3->4->5->null,反转完成后则变为5->4->3->2->1->null。(null是空指针,代表链表的结束)
当然,本题自然也可以不用递归,直接使用迭代法(iterative)来解决,对于每一个节点,都保存其前驱(pre)以及后继(next)两个节点,不断进行原地(in-place)逆序即可。
此处还是以递归法来说明所谓“递归三要素”的理解应用:
对于这样一个链表,实际上用于保存它的方法是很简单的,它只保存了一个头节点,而每一个节点定义如下:
class ListNode{ int data; Node next; }
每一个节点只保存了自己的数据(此处是int)以及下一个节点的引用(或者说是地址,但是java没有指针,所以其实是下一个节点的引用)。
因此,这个问题天然地具有一种类似于数学上自同构(auto-morphism)的感觉,也就是问题可以被分解为对于每一个节点进行处理。
1.递归的定义:我们可以试着定义一个递归函数,它只处理一个给定节点,返回的是已经被处理好的链表的第一个节点。(比如说,对于1-2-3-4-5,如果输入一个3,返回的是5,对应的其实就是5-4这样一个被处理好的部分,随后将3再接到5-4之后,形成1-2-3以及5-4-3的情况)
2.递归的拆解:由于我们一开始只知道头节点head,所以比较合理的递归/前进方式是,每次输入一个head.next,也就是向后一次遍历一个引用,这也是合理的,因为从数据结构上来看,我们也只能作这样的访问。
3.递归的出口:到什么情况我们可以返回一个处理好的链表呢?其实这时对应的往往都是基础/平凡(trivial)的情况。对于本题,就是返回空指针、单个节点的情况(因为这样的情况不需要再反转了)。
由此我们可以给出代码:
// 递归的定义:下面的函数返回的是,将给定节点之后(包括这一节点)所有的节点反转之后的链表的头节点 // 输入:一个给定的节点 // 输出:包含本节点在内的反转链表的头节点 public ListNode reverseList(ListNode head){ // 递归的出口:当是空指针或者单个节点时,返回其本身 if(head == null || head.next == null) return head; // 递归的拆解:一个新的反转链表 = 当前节点之后的反转链表 + 将当前节点移动到已有的反转链表之后 ListNode next = reverseList(head.next); head.next.next = head; // 注意,在修改head.next之前,head.next指向的依旧是原来的后续节点 head.next = null; return next; // 返回新的反转链表 }
应该说,这个代码基本体现出了递归的三要素,在之后的练习中,也应该多思考递归函数的设计,而不是凑对了、看懂了就草草带过去,相关的设计思想往往就被遗漏了。
对于这一话题,下一步的计划:
1.练习更多、难度更大的题目
2.阅读一些算法教材,从更底层和本质的角度思考递归问题
Reference: