如何写出正确的递归?
递归的难点
在写递归程序的时候最容易陷入的一个思维误区是试图用人脑去模拟递归程序的执行。
如果我们企图在脑中模拟一层层的递归调用,就很容易被绕进去,从而导致思维越来越混乱。追踪递归的调用流程可以帮助我们更好的理解递归,但在写程序时,可千万别这么干。
递归的两个要素
在编写递归的代码时,只要把握好以下两个要素,就能写出正确的递归代码。
1. 终止条件(base case)
递归不能无限地调用下去,当能够直接得到问题的答案时,这就是递归的终止条件或者叫做base case。
2. 递推关系
递推关系表达了原问题与子问题之间的关系。
递归的实例
我们通过两个实例来理解如何把握上面说的两个要素。
1. 斐波那契数列
这是一个经典的递归的实例,我们不去考虑递归的效率,只考虑如何写出正确的递归。
很显然,这个实例具有很明显的数学关系,因此,我们不难得出递归的两个要素:
终止条件:f(0)= 0, f(1) = 1
递推关系:f(n)= f(n - 1)+ f(n - 2)
很容易得到以下代码:
public int fib(int n) {
if (n < 2) {
return n;
}
return fib (n - 1) + fib (n - 2);
}
2. 反转链表
反转链表也是一个很经典的可以利用递归来解决的问题。但是反转链表就不存在很明显的数学公式了,那么,我们应该怎么来写递归的代码呢?
我们还是牢牢把握上面说的两个要素,分析怎样写出正确的递归代码。
终止条件:什么情况下是终止条件?就是不需要反转链表的情况,很显然,当链表中没有结点或者只有一个结点的时候不需要反转链表,也就是 head == null 或者 head.next == null 的情况。
递推关系:没有数学公式该如何分析递推关系呢?实际上,递推关系只是一个抽象的概念,表示的是原问题与子问题的关系, 也就是如何在子问题的基础上解决原问题。以这个例子为例,递推关系就是分析如何反转当前结点 head 和 子问题 reverseList(head.next) (假设当前结点已反转,则子问题就是反转下一个结点,即 head.next)。
我们无需考虑子问题是如何递归展开的,我们只需要了解子问题执行结果即可。也就是说我们在思考的时候,只需要假设 reverseList(head.next) 返回的是一个已经反转好的链表就行了。
如图所示,方框内就代表我们假设的递归调用的结果,因此,我们需要思考的就是如何反转当前结点
head 和 子问题 reverseList(head.next) 。显然,我们需要让 2 的 next 指针指向 1 , 同时让 1 的 next 指针指向 null 。
因此,我们可以得到如下的“递推关系”(即如何在子问题的基础上解决原问题)
head.next.next = head; // 让 2 指向 1
head.next = null; // 让 1 指向 null
有了上述的理解,就不难写出完整的代码了。
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = reverseList(head.next); // 需要返回反转的链表的头结点
head.next.next = head;
head.next = null;
return newHead;
}
总结
在写递归代码的时候,只要把握好递归的两个要素,而不必去思考具体的递归过程,就能防止陷入思维误区,从而写出正确的递归代码。可能很多人习惯了在脑子中模拟这个过程,这确实一个需要适应的过程,也许这就是计算机思维和人脑思维的区别吧,要想写好递归,就必须学会从计算机的角度去思考问题,培养自己的计算思维。