递归
递归算法是编程中很重要的算法,能把很多问题化繁为简,而对新手程序员来说,比较难以理解和运用。
递归简单的理解就是自己定义自己,在程序中就是自己调用自己。
我们先看一个简单的示例,写程序求n的阶乘。
n! = n * (n - 1) * (n - 2) * ...* 2 * 1
最简单的代码如下:
int main(object me, string arg)
{
int n = atoi(arg), res = 1;
for (int i = 1; i <= n; i++)
{
res *= i;
}
printf("%d! = %d\n", n, res);
return 1;
}
我们知道 n! = n (n - 1)!, (n - 1)! = (n - 1) (n - 1 - 1)!……,这其中包含了递归结构。
用递归函数自己调用自己来求解,代码如下:
int fn(int n)
{
if (n == 0)
{
return 1;
}
else
{
return n * fn(n - 1);
}
}
int main(object me, string arg)
{
int n = atoi(arg);
printf("%d! = %d\n", n, fn(n));
return 1;
}
只是从代码看,这个递归并没有让代码简单,反而看起来复杂了,这里重点是理解递归的思维:把复杂问题转换为较为简单的同类问题。对 n! 我们先求 (n-1)!的阶乘,一直递归到求 0!。 0! 也是递归的结束条件,这一点非常重要,否则会进入死循环。
下面我们再用另一个经典问题来理解递归,通过代码求汉诺塔(Tower of Hanoi)的解法。
汉诺塔(Tower of Hanoi)起点柱A、中转柱B、目标柱C,对1层汉诺塔,我们直接把圆盘从起点柱A移动到目标柱C,对2层汉诺塔,我们需要先把1层圆盘从起点柱A移动到中转柱B,再把2层圆盘从起点柱A移动到目标柱C,最后把1层圆盘从中转柱B移动到目标柱C。而对3层汉诺塔,可以先按2层汉诺塔的解法把前2层圆盘移动到中转柱B上,再然后把3层圆盘移动到目标柱C上,最后按2层汉诺塔的解法把前2层圆盘从中转柱B移动到目标柱C上。
对 n 层汉诺塔的解法:
- 先把 n - 1 层圆盘从A柱移动到B柱(解出 n-1 层)
- 再将第 n 层圆盘从A柱移动到C柱
- 最后将 n - 1 层圆盘从B柱移动到C柱(解出 n-1 层)
因为在解 n - 1 层时中转柱B相当于 n - 1 层解法的目标柱,就是在不同层的情况下,起点柱、中转柱、目标柱可能是不同的,不是固定的起点A、中转B、目标C,而是变量x、y、z。现在上代码:
void hanoi(int n, string x, string y, string z)
{
if (n)
{
hanoi(n - 1, x, z, y);
printf("移动圆盘 %d :从 %s 到 %s\n", n, x, z);
hanoi(n - 1, y, x, z);
}
}
int main(object me, string arg)
{
hanoi(atoi(arg), "A", "B", "C");
return 1;
}
通过以上递归,很简单的代码就把任意层的汉诺塔解法写出。
> test/hanoi 1
移动圆盘 1 :从 A 到 C
> test/hanoi 2
移动圆盘 1 :从 A 到 B
移动圆盘 2 :从 A 到 C
移动圆盘 1 :从 B 到 C
> test/hanoi 3
移动圆盘 1 :从 A 到 C
移动圆盘 2 :从 A 到 B
移动圆盘 1 :从 C 到 B
移动圆盘 3 :从 A 到 C
移动圆盘 1 :从 B 到 A
移动圆盘 2 :从 B 到 C
移动圆盘 1 :从 A 到 C
在编程中要活用递归,包括后面重要的排序算法中有很多算法都用了递归的方式完成排序。
递归算法的致命缺陷
需要注意的是,递归运算是有致命缺陷的,并不是能用递归的优先用递归,我们以计算斐波那契数为例,以下是LPC代码:
// 递归计算斐波那契数
int fib(int n)
{
if (n <= 2)
{
return 1;
}
else
{
return fib(n - 1) + fib(n - 2);
}
}
int main(object me, string arg)
{
int n = to_int(arg);
printf("fib(%d) = %d\n", n, fib(n));
return 1;
}
当n很小时性能没什么影响,可当n超过30后运算时间明显变长,当n到40时已经快不行了。
因为递归函数内部嵌套了对自身的调用,除非等到最内层的函数调用结束,否则外层的所有函数都不会调用结束。通俗地讲,外层函数被卡主了,它要等待所有的内层函数调用完成后,它自己才能调用完成。而且每一层的递归调用都会在栈上分配一块内存,有多少层递归调用就分配多少块相似的内存,所有内存加起来的总和是相当恐怖的,很容易超过栈内存的大小限制,这个时候就会导致程序崩溃。另外,每次调用函数都会在栈上分配内存,函数调用结束后再释放这一部分内存,内存的分配和释放都是需要时间的,所有的这些时间加在一起是非常恐怖的。
递归函数的解决方案存在巨大的内存开销和时间开销,而且这还是函数实现原理层面的缺陷,无法优化。
迭代
好消息是,大部分能用递归解决的问题也能用迭代来解决。所谓迭代,就是循环。
许多问题是以递归的形式进行解释的,这只是因为它比非递归形式更为清晰。但是,这些问题的迭代实现往往比递归实现效率更高,虽然代码的可读性可能稍差一些。
与递归函数相比,迭代不但没有额外的内存开销,也没有额外的时间开销。
下面使用迭代的实现的代码:
//迭代计算斐波那契数
int fib(int n)
{
int result;
int previous_result;
int next_older_result;
result = previous_result = 1;
while (n > 2)
{
n -= 1;
next_older_result = previous_result;
previous_result = result;
result = previous_result + next_older_result;
}
return result;
}
int main(object me, string arg)
{
int n = to_int(arg);
printf("fib(%d) = %d\n", n, fib(n));
return 1;
}
测试数据哪怕是n = 100 也是秒出结果,而用递归到40就卡半天了。提示:具体耗时可以使用time_expression这个efun获取,如:time_expression(fib(n)),这里不做演示。
所以在开发中,可以用递归做小数据的运算和减化问题的算法解释,而在代码实现时能用迭代的优先用迭代。