递归法的理解——以反转链表为例

Posted mingyu-li

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了递归法的理解——以反转链表为例相关的知识,希望对你有一定的参考价值。

2020-01-07

递归是什么:

递归,从定义上说,指的是某个函数直接或者间接调用自己时,则发生了递归。

 

比如说著名的斐波拉契数列的实现方法之一:

1 public static int f(int n){
2 
3     if(n == 1 || n == 2) return 1;
4 
5     return f(n-1) + f(n-2);
6 
7 }

 

在这个例子中,对于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)的情况。对于本题,就是返回空指针、单个节点的情况(因为这样的情况不需要再反转了)。

由此我们可以给出代码:

 

 1 // 递归的定义:下面的函数返回的是,将给定节点之后(包括这一节点)所有的节点反转之后的链表的头节点
 2 // 输入:一个给定的节点
 3 // 输出:包含本节点在内的反转链表的头节点
 4 public ListNode reverseList(ListNode head){
 5     // 递归的出口:当是空指针或者单个节点时,返回其本身
 6     if(head == null || head.next == null) return head;
 7     
 8     // 递归的拆解:一个新的反转链表 = 当前节点之后的反转链表 + 将当前节点移动到已有的反转链表之后
 9     ListNode next = reverseList(head.next);
10     head.next.next = head; // 注意,在修改head.next之前,head.next指向的依旧是原来的后续节点
11     head.next = null;
12     return next; // 返回新的反转链表 
13 }

 

 应该说,这个代码基本体现出了递归的三要素,在之后的练习中,也应该多思考递归函数的设计,而不是凑对了、看懂了就草草带过去,相关的设计思想往往就被遗漏了。

 

对于这一话题,下一步的计划:

1.练习更多、难度更大的题目

2.阅读一些算法教材,从更底层和本质的角度思考递归问题

 

 Reference:

[1] https://coding.oi-wiki.org/basic/divide-and-conquer/

[2] https://v2ex.com/t/628435

以上是关于递归法的理解——以反转链表为例的主要内容,如果未能解决你的问题,请参考以下文章

LeetCode 剑指 Offer 24. 反转链表

算法LeetCode 反转链表(递归)关键步骤理解

一文读懂链表反转(迭代法和递归法)

在c中递归地反转链表

剑指Offer:反转链表

LeetCode Java刷题笔记—206. 反转链表