算法入门 - 递归的原理及应用
递归是一种比较绕的算法,这是因为它通常在我们肉眼所见的范围内无法完成调用。迄今为止,我们学习的数组、链表等,实现的代码都是从上至下依次执行的,即便会有循环,但也是在可控范围内进行的操作。而递归却有一种无法掌控的感觉,跑着跑着就不知道去哪了。初学这种算法的同学,经常会陷入一层层的调用中,搞得头脑发晕,时而明白,时而糊涂,真的是让人头疼!🤣
实际上,是我们把递归想得过于复杂了,从本质上来说,它就是函数调用,只不过调用的是它自己。
1.从分而治之的角度看递归
递归实际上是一种分而治之的思想,一个问题,如果一下子搞不定,能否只搞定一部分呢?如此进行拆解下去,便将一个复杂的问题分解成了多个较为简单的问题,直到碰到一个问题是显而易见可解决的。我们再拿这个解决了的问题,去求解它上一层的问题,再这么反向求解下去,就可以得到最初问题的解了!
下面我们以计算阶乘为例,来详细看一下递归的过程。
我们都知道,一个数的阶乘就是从这个数开始做乘法,每次减一后继续乘,直到这个数为1(1!)。假设要求10的阶乘,就是10*9*8*7*6*5*4*3*2*1。如果用递归的思维来看,我们一下子无法求出10!,但我们知道,10! = 10 * 9!,所以问题就拆解为求9!,9!也无法直接求出,所以再拆解为 9 * 8!,一直这么拆解下去,直到 2! = 2 * 1!,此时我们知道,1! = 1,这就是我们显而易见可解决的问题,再这么反向求解过去,就可以得到最终的结果。
public class FactNum{
public static int factorial(int n){
// 如果n为1,就直接返回1
if (n == 1) return 1;
// 否则就简化为求n-1的阶乘
return n * factorial(n-1);
}
public static void main(String[] args) {
int result = factorial(5);
System.out.println("5的阶乘为: " + result);
}
}
/*
输出结果:
5的阶乘为: 120
*/
代码很简短,只是重复做了两件事:
1.每次都看一下当前的数 n 是不是1,如果是1,就直接返回1;
2.否则就返回 n 乘以 n-1 的阶乘。
实际上,我们可以把这个显而易见可解决的问题当做是退出条件,然后思考当前问题怎么拆解比较合适。只要分两步,就可以搞定递归算法。
2.从语义的角度看递归
还有一点需要注意,我们要时刻记得递归函数(方法)的功能,以及我们每一步要做的事情,尝试使用语义,会让整个过程更加自然。
例如,我们要求 n 的阶乘 factorial(n),如果 n 等于 1,就直接返回 1;如果不是 1,我们就讲问题转化为求 n*(n-1) 的阶乘,而 (n-1) 的阶乘正好可以通过函数 factorial(n-1) 求得:
// 一种更加直观的理解方式
public static int factorial(int num){
if (num == 1) {
return num;
}
// 不看做递归调用,仅作为普通函数调用,目的就是求 num-1 的阶乘
int fact = factorial(num - 1);
int result = num * fact; // 求出 num-1 的阶乘,就求出了n的阶乘
return result;
}
我们仅从功能的角度调用函数,而不去钻递归的角尖,同样可以写出递归的代码!
3.从栈的角度看递归
还可以把递归想象成是模拟栈的压入,调用一次就相当于将函数压入栈,然后从最后一层开始出栈:
下面我们就再看一个链表的例子,再次强化下对递归的理解。
实例:根据元素值删除链表节点
继续我们之前链表的例子,当时我们只实现了按照索引值删除节点的方法:
// 移除节点
public E remove(int index) {
if (index < 0 || index >= getSize()) throw new IllegalArgumentException("index must > 0 and < size!");
if (getSize() == 0) throw new IllegalArgumentException("Empty Queue, please enqueue first!");
// prev节点的初始值为dummyHead
Node prev = dummyHead;
// 通过遍历找到prev节点
for (int i = 0; i < index; i++) prev = prev.next;
// 储存待删除节点
Node delNode = prev.next;
// 跳过delNode
prev.next = delNode.next;
// 待删除节点后接null
delNode.next = null;
size--;
return delNode.element;
}
public E removeFirst() {
return remove(0);
}
public E removeLast() {
return remove(getSize() - 1);
}
实际上我们还可以根据元素值来删除节点。这里会有一个问题,链表是不限制重复元素的,那么是只删除符合条件的第一个节点,还是删除所有符合条件的节点呢?这里我们选择删除所有符合条件的节点,所以递归就能派上用场了!我们先来看下不使用递归的实现方式:
// 非递归方式实现
public void removeElementAll(E element){
if(isEmpty()) throw new IllegalArgumentException("Empty list, add first!");
Node prev = dummyHead;
while (prev.next != null){
if (prev.next.element == element){
Node delNode = prev.next;
prev.next = delNode.next;
delNode.next = null;
size--;
}
else prev = prev.next;
}
}
实现方式很简单,使用 while 循环遍历链表,只要 prev.next 节点的元素值与待删除元素值相等,就删除该节点。
如果采用递归的方式,该如何拆解问题呢?我们无法一下子就找出所有符合条件的节点,但是可以把链表拆解成两部分,一个头节点和一个子链表。这样就把原链表转化成了一个单独的节点和另一个较短的链表。如果节点为空,说明已到链表尾部(显而易见的答案,即退出条件),返回空值即可。例如删除一个链表中所有包含元素6的节点:
public void removeElementAll(E element){
// 从head开始删除元素,并更新链表
dummyHead.next = removeElementAll(dummyHead.next, element);
}
private Node removeElementAll(Node head, E element){
// 如果当前头节点为空,说明链表结束,返回null
if (head == null) return null;
// 处理头节点后的子链表并更新
head.next = removeElementAll(head.next, element);
// 如果当前头节点为待删除节点,则将头节点的next作为头节点返回
if (head.element == element) return head.next;
// 否则返回当前头节点
return head;
}
这里使用了两个方法,第一个是对外调用的方法,只需要传入要删除的元素值即可。第二个是内部的 private 递归方法,我们结合动图来看,每次子链表往下掉一层,就代表一层递归。可以发现,因为我们的代码是先处理子链表,所以在正向递归的过程中并没有判断当前头节点是否该删除。直到头节点为空时,我们开始向上返回,并开始判断头节点,如果当前头节点为待删除节点,则只返回当前头节点的子链表,否则就连同当前节点一并返回到上一层。通过这样比较直观的过程,应该能够更好地理解递归了。