「递归」这个词语我们经常在很多地方看到,在很多地方用到。但是初学递归时可能会有些难以理解。本文从一些易懂、常见的例子中介绍一下「递归」。
当我们看到「递归」时,不要把它看成一个词语,而是分开看成两个字「递」和「归」。
举一个生活中的例子
有几个人在柜台前排队,现在甲想知道他排到第几个了,所以他会问排在他前面的乙是第几个,然后加1即可。
但是乙也不知道他是第几个,所以乙会问排在他前面的丙是第几个,然后加1即可。
这样一直向前问……
直到问到戊了,此时戊就站在柜台前,所以戊知道他是第1个,然后回答丁。
丁知道他前面的人是第1个,那么丁就知道他是第2个了,然后回答丙。
这样一直向后回答……
甲知道了他前面的人是第4个,那么甲就知道他是第5个了。
在这个例子中,大问题是「甲想知道他是第几个人」,但是这条队伍排的太长了一眼望不到头,或者甲忘记带眼镜了看不清。所以甲不能一下解决这个大问题,那么他只能问排在他前面的乙是第几个,这样就把这个大问题分解成一个小问题:「乙想知道他是第几个人」,当甲解决了小问题,那么大问题也就迎刃而解了。
那么乙是第几个人呢?这就不是甲操心的事了,因为甲也不知道乙是第几个人。不幸的是,乙也不知道他自己是第几个人,并且乙也懒得数,所以乙也直接问他前面的丙是第几个。那个小问题又被分解成更小的问题:「丙想知道他是第几个人」。
就这样你问我,我再问他,一直问下去,问题也变得越来越小。虽然越来越小,但是问题的本质并没有变:都是「某人想知道他自己是第几个人」。
当问题来到戊这里,问题被分解到足够小足够简单了:「戊站在柜台前,戊想知道他是第几个人」,戊不用数,也不用问其他人就知道自己是第1个人。为什么?因为他站在柜台前,也就是说,当他看到柜台时,就知道自己是第1个人了。
然后开始不断向后回答,最后甲知道自己是第5个人。
总结一下该例的几个要点:
- 后面的人在询问前面的人(人在问人)
- 每个人在做同样的动作
- 问题在不断变小(简单),但是不论多小,都与最初的大问题一样,目的不变
- 问题没有被无限地问下去,到柜台前,最简单的问题被解决
- 最简单的问题被解决后,开始向后解决问题,直到解决最初的大问题
将这个几个要点对应到具体的编程中就是:
- 方法自己调用自己
- 有重复的逻辑
- 问题在不断变小(简单),但是目的不变
- 方法没有无限的自己调用自己,当问题变为最简时(满足终止条件)停止调用。
- 执行完终止条件(if语句)后,开始返回
根据以上要点得出了一个普遍流程:(这里先写出来,详细解释在文末总结中)
第一步:找到「大问题」是什么?
第二步:找到「最简单的问题」是什么?满足最简单问题时应该做什么?
第三步:找到「重复逻辑」是什么?
第四步:自己调用自己
第五步:返回
下面看几个简单的例子:
例1:给定数字n,打印数字从n至1
第一步:找到大问题是什么?
打印数字从n至1
public static void print(int n) {
}
第二步:找到最简单的问题是什么?满足最简单问题时应该做什么?
当数字为0时,不需要打印,所以「打印数字0」是最简单的问题,满足时停止打印
//打印数字从n至1
public static void print(int n) {
if (n == 0)//最简单问题
return;
}
第三步:找到重复逻辑是什么?
打印每个数字
//打印数字从n至1
public static void print(int n) {
if (n == 0)//最简单问题
return;
System.out.println(n);//重复逻辑
}
第四步:自己调用自己
//打印数字从n至1
public static void print(int n) {
if (n == 0)//最简单问题
return;
System.out.println(n);//重复逻辑
print(n - 1);//自己调用自己
}
第五步:返回
方法没有返回值,所以不返回
例2:等差数列,给定第一项为a,公差为d,求第n项的值
如,第一项为1,公差为2的等差数列为:1,3,5,7,9,11……
第一步:找到大问题是什么?
求第n项的值
//已知a:第一项,d:公差,n:求第几项
public static int f(int a, int d, int n) {
}
第二步:找到最简单的问题是什么?满足最简单问题时应该做什么?
第1项是已知的,不需要计算,所以「求第1项的值」是最简单的问题,满足时直接返回值
//a:第一项,d:公差,n:求第几项
public static int f(int a, int d, int n) {
if (n == 1)//最简单问题
return a;
}
第三步:找到重复逻辑是什么?
第n-1项 + 公差d = 第n项
//a:第一项,d:公差,n:求第几项
public static int f(int a, int d, int n) {
if (n == 1)//最简单问题
return a;
return f(a, d, n - 1) + d;//重复逻辑
}
第四步:自己调用自己
//a:第一项,d:公差,n:求第几项
public static int f(int a, int d, int n) {
if (n == 1)//最简单问题
return a;
return f(a, d, n - 1) + d;//重复逻辑、自己调用自己
}
第五步:返回
//a:第一项,d:公差,n:求第几项
public static int f(int a, int d, int n) {
if (n == 1)//最简单问题
return a;
return f(a, d, n - 1) + d;//重复逻辑、自己调用自己、返回
}
例3:斐波那契数列(1,1,2,3,5,8,13……),求第n项的值
第一步:找到大问题是什么?
求第n项的值
//求第n项
public static int f(int n) {
}
第二步:找到最简单的问题是什么?满足最简单问题时应该做什么?
第1项和第2项是已知的,不需要计算,所以「求第1项或第2项的值」是最简单的问题,满足时返回1
public static int f(int n) {
if (n < 3)//最简单问题
return 1;
}
第三步:找到重复逻辑是什么?
某项值等于其前两项值之和
public static int f(int n) {
if (n < 3)//最简单问题
return 1;
return f(n - 1) + f(n - 2);//重复逻辑
}
第四步:自己调用自己
public static int f(int n) {
if (n < 3)//最简单问题
return 1;
return f(n - 1) + f(n - 2);//重复逻辑、自己调用自己
}
第五步:返回
public static int f(int n) {
if (n < 3)//最简单问题
return 1;
return f(n - 1) + f(n - 2);//重复逻辑、自己调用自己、返回
}
例4:反转一个单向链表(LeetCode第206题)
第一步:找到大问题是什么?
反转一个单向链表
public ListNode reverseList(ListNode head) {
}
第二步:找到最简单的问题是什么?满足最简单问题时应该做什么?
当链表为空链表或只有一个结点时,无所谓正反,所以「反转空链表或只有一个结点的链表」是最简单的问题,满足时直接返回头结点
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {//最简单问题
return head;
}
}
第三步:找到重复逻辑是什么?
我们以只有两个结点的链表为例,过程如下:
多结点的链表也是在重复上图过程,所以重复逻辑是:将每个指针反转方向
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {//最简单问题
return head;
}
head.next.next = head;//重复逻辑
head.next = null;//重复逻辑
}
第四步:自己调用自己
下面是一个多结点链表
如果从head开始将每个指针反转,就会出现下面的情况:结点2后面的结点都丢了。
所以从head开始行不通,那就从最后一个结点开始。刚好从最后一个结点开始反转时,正好是最简单的情况。
如上图:每个框都可以看做是一个链表,大链表的子链表也是一个链表,所以解决方式是一样的,刚好符合大问题不断分解成小问题。
那么,如何从最后一个结点开始呢?答案是自己调用自己
在自己调用自己的过程中,链表不断变小,最后成为最简单问题
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {//最简单问题
return head;
}
ListNode p = reverseList(head.next);//自己调用自己
head.next.next = head;//重复逻辑
head.next = null;//重复逻辑
}
第五步:返回
需要返回反转后的链表的头结点,用变量p接收
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {//最简单问题
return head;
}
ListNode p = reverseList(head.next);//自己
head.next.next = head;//重复逻辑
head.next = null;//重复逻辑
return p;//返回
}
总结
上面的例子可以分为两类:
-
数据的定义是按递归定义的。如斐波那契
-
问题解法按递归算法实现。如反转链表
除此之外还有一类,
- 数据的结构形式是按递归定义的。如二叉树
现在再看一下流程:
第一步:找到大问题是什么?
第二步:找到重复逻辑是什么?
第三步:找到最简单的问题是什么?满足最简单问题时应该做什么?
第四步:自己调用自己
第五步:返回
通过上面几个例子,可以看出,递归就是解决大问题的过程。
那么什么是大问题?
大问题就是一开始想要做的事,如例4中的反转整个链表。
但是由于问题太“大”,所以可能不容易解决,那么就需要把大问题分解成小问题。如例4中将「反转5个结点的链表的问题」分解成「反转4个结点的链表的问题」。
那么什么是小问题?
小问题和大问题在本质上是一样的,他们都是在「用同样的方式解决同样的问题」,不同的只是问题的规模大小。如「反转5个结点的链表的问题」和「反转4个结点的链表的问题」,问题和解决方式是一样的。
如果小问题还不容易解决,那么就继续分解。这个分解的过程就是递归的「递」。
那么如何分解?
方法自己调用自己就是在不断分解问题,一定要注意方法的参数,参数才是「递」的关键。
如例3中的f(n-1)+f(n-2),如例4中的reverseList(head.next),参数一直在变小,参数变小说明问题在不断变小。
分解的目的是「使大问题变成一系列等价的小问题,从而解决大问题」,但是不能一直分解下去,当满足一定条件时,要停止分解。
那么什么时候停止?
当将大问题分解成最简单的问题(也称为终止条件)时,就可以停止分解了。
那么什么是最简单的问题?
如柜台例子中第1个人看到柜台时、例1中打印数字0时、如例3中求第1项或第2项的值时、如例4中反转空链表或只有一个结点的链表时,这些都是最简单问题。
这些问题的共同特点是「问题已经足够简单,不需要再分解就可以直接解答」,最简单的问题(终止条件)通常是if语句的形式,当满足时直接返回结果。并且,递归中的 「归」从此开始。
以上几个「那么」构成了递归的基本形式,但是并不能解决问题(大问题、小问题),因为空有形式却没有方式,真正解决问题的方式是重复逻辑。
那么什么是重复逻辑?
可以看成是分解过程(递)在重复做的事,也可以看成是每个问题(大问题、小问题)的解法,解决最简单问题不使用该解法。如例1中的打印语句,例2中的f(n-1) +d ,例3中的f(n-1) + f(n-2),例4中的反转指针。
解决完问题后(注意解决问题的顺序:先解决最简单问题,最后解决大问题),需要把值返回。
那么怎么返回?
使用return语句返回。注意:解决某个小问题后得到的值返回给上一个大问题,用来解决上一个大问题。
最终解决了大问题。
仔细看一下上面几个「那么」的叙述过程,也是递归的。
一句话总结:
通过分解(递)将大问题不断分解成小问题直至最简单的问题。然后从最简单的问题开始解决,解决每个问题得到的值被返回,用来解决上一个较大的问题(解决问题时使用重复逻辑)(归),直到解决了最大问题。
如有错误,还请指正。
文章首发于公众号『行人观学』