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

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

原文地址:https://www.cnblogs.com/mingyu-li/p/12161704.html

时间: 2024-11-13 04:38:13

递归法的理解——以反转链表为例的相关文章

【剑指offer】递归循环两种方式反转链表

转载请注明出处:http://blog.csdn.net/ns_code/article/details/25737023 本文分别用非递归和递归两种方式实现了链表的反转,在九度OJ上AC. 题目描述: 输入一个链表,反转链表后,输出链表的所有元素.(hint : 请务必使用链表) 输入: 输入可能包含多个测试样例,输入以EOF结束.对于每个测试案例,输入的第一行为一个整数n(0<=n<=1000):代表将要输入的链表的个数.输入的第二行包含n个整数t(0<=t<=1000000)

递归法

递归法(Recursion)是一种在函数或方法中调用自身的编程技术,在计算机方法中,使用递归技术往往使函数的定义和算法的描述简洁且易于理解.任何可以用计算机求解的问题所需要的计算时间都与其规模有关.而且规模越小,解题所需要的计算时间通常越小,从而比较容易处理. 简而言之,递归思想就是用与自身问题相似但规模较小的问题来描述自己. 例如,兔子出生两个月后就有繁殖能力,一对兔子每个月能生出一对兔子来.如果所有兔子都不死,那么一年以后可以繁殖多少对兔子? 第一个月小兔子没有繁殖能力,所有还是一对:两个月

谭浩强 c程序设计 8.17用递归法将一个整数n转换成字符串。例如,输入486,应输出字符串&quot;486&quot;。n的位数不确定,可以是任意位数的整数。

8.17用递归法将一个整数n转换成字符串.例如,输入486,应输出字符串"486".n的位数不确定,可以是任意位数的整数. #include <stdio.h> char str1[20];int i=0;long n;int main(){        int longToStr(long n);    char *revstr(char *str, int len);    printf("请输入一个整数n:\n");    scanf("

2.简单求和[递归法]

计算1+2+3+4+5,用递归法的两种形式,练习这题主要查看递归规律,训练递归,形成递归的[本能].第一种,从后往前加. int sum(int a[],int n) { return n==0?0:sum(a,n-1)+a[n-1]; } 第二种,从两边对加,例:(1+5)+(2+4)+3,这个需要判定,假如数组的个数为偶,则左边+1==右边,如果为奇数,则左边+2==右边,需要将这三个数之和返回: int sum(int a[],int l,int r) { if(l+2==r)/*中间隔一

C语言之函数调用13—递归法求N阶勒让德多项式的值

//递归法! /* ======================================================= n阶勒让德多项式,n=1时,Pn(x)=x;n>=1时, Pn(x)=((2n-1)x-Pn-1(x)-(n-1)Pn-2(x))/2. ======================================================= */ #include <stdio.h> #include <math.h> double p(

C语言之函数调用14—递归法打印勒让德多项式前N项

//递归法 /* ================================================================== 题目:勒让德多项式 ================================================================== */ #include <stdio.h> double p(int n,double x) { if(n==0)return 1.0; else if(n==1)return x; else

C语言之函数调用16—递归法之一般函数的调用

//递归法 /* ================================================================== 题目:F(x,1)=1 F(x,n)=F(2x+1,n-1)*x  (n>1) ================================================================== */ #include<stdio.h> double F(float x,int n) { if(n==1) return

循环赛日程表(递归法)

#include<iostream> #include<vector> #include<iterator> #include<algorithm> using namespace std; /* *循环赛日程表(递归法) */ void Copy(int **map,int sr,int sl,int dr,int dl,int k) { for (int i = 0; i < k; i++) { for (int j = 0; j < k;

简单粗暴地理解js原型链--js面向对象编程

简单粗暴地理解js原型链--js面向对象编程 原型链理解起来有点绕了,网上资料也是很多,每次晚上睡不着的时候总喜欢在网上找点原型链和闭包的文章看,效果极好. 不要纠结于那一堆术语了,那除了让你脑筋拧成麻花,真的不能帮你什么.简单粗暴点看原型链吧,想点与代码无关的事,比如人.妖以及人妖. 1)人是人他妈生的,妖是妖他妈生的.人和妖都是对象实例,而人他妈和妖他妈就是原型.原型也是对象,叫原型对象. 2)人他妈和人他爸啪啪啪能生出一堆人宝宝.妖他妈和妖他爸啪啪啪能生出一堆妖宝宝,啪啪啪就是构造函数,俗