一般程序设计有三大结构——顺序、选择和循环,循环结构是程序结构中最重要的组成部分,没有它就难以实现程序的自动化,只能在有限的若干选择和执行后就终止了。递归可以看作一种循环,其底层是利用栈的实现,因此比一般的迭代循环(for循环、while循环和do-while循环)多花费一定的空间成本,但是有时候写递归函数能让程序的逻辑更加简明。
例如下面是两个计算阶乘的方法,一个使用循环语句迭代,另一个是使用递归,而递归的代码较为简明些。
介绍
public static int fac(int n){
if(n<=1)return 1;
return n*fac(n-1);
}
public static int fac2(int n){
int res=1;
if(n==0)return 1;
for(int i=1;i<=n;i++){
res*=i;
}
return res;
}
这两个代码都是正确的,都是为了算出非负整数n的阶乘数,在第二段代码中,阶乘计算的具体流程已被写得一清二楚,每次都和自动增加的i相乘,最终共乘了1,2,3,...,n,于是res便等于了1*2*3*...*n。对于第一段代码,为了计算n的阶乘,需要计算n-1的阶乘,然后再用n乘以n-1的阶乘就是n的阶乘了。而计算n-1的阶乘有是与前面同样的方法,即递归调用了方法本身。当n<=1,时,n的阶乘就是1,这是其中一行代码。如果假设所编写的方法fac(int n)能够实现输入一个非负对整数n返回一个n地的阶乘的功能,那么,fac(n-1)就被正确地计算了出来,并乘以n,最终n的阶乘被正确地计算出来了。上面的这段描述类似于数学归纳法的证明过程。实际上,设计递归代码时就应当这样考虑。
另外,递归的过程是一个递推的过程,它将原问题的规模减小一分,去解决规模较为小的子问题,而子问题的性质可能和原问题的一样,并且建立了当前问题的解与子问题之间的关系,这种关系就是递推关系,可以用递推表达式来表示。例如上面的求n的阶乘的递归例程,原问题是要求出n的阶乘fac(n),递归调用实际上是求解规模较小的子问题——n-1的阶乘,它们之间的关系是fac(n)=n*fac(n-1),这是一个递推关系,找到递推关系是设计递归代码的关键。
并不是所有递归算法都比普通的迭代循环高效,有些时候会存在一些及其低效的递归实现,在这方面,似乎计算斐波那契数比较经典,于是我们来看看。
我们定义一列斐波那契数是这样的:它的第一项和第二项都是1,从第三项开始,每一项的值都是其前两项的和,如果第一项从0开始数起,那么会有如下的递推关系式:
显然这样的递推关系式是已知的,但有时候递推关系式并不是明显的,我们需要自己找出正确的递推关系式。按照上述的 关系式,我们写出了如下递推代码。
public static int fib(int n){
if(n<=1)return 1;
return fib(n-1)+fib(n-2);
}
注意,我们可以使用第二数学归纳法可以证明fib(n)这一递推函数的正确。通常3我们假设的是某个n,相应的命题P(n)成立,在推出P(n+1)也成立,我们可以假设一切小于n的相应命题k(k<=n)都成立。如上的 代码,我们假设一切小于n-1的相应调用fib(k)(k<=n-1)都能计算出斐波那契项,再于基准情况合并,我们就认为此代码是可行的。
不过话说回来,它为什么是低效的呢?我们可以计算此递归算法的运行渐进时间,我们假设在一次if语句判断和一次加法分别最多用时一个单位长度,根据递推关系,设T(n)为输入n,输出结果后算法所花费的中运行时间,有如下的式子:
当n等于0或1时,只执行了一条if语句就返回,若n大于1,执行了包含if语句和一次加法再加上两次调用。可以证明,对充分大的n,有
这说明T(n)是指数级增长的。
为什么会这样呢?这是因为每次fib(n-2)都被计算了两次,浪费了时间,在计算fib(n)时就需要计算fib(n-2)。如果将其记录下来,那么就会节省不少时间。 可以这样:
public static int fib(int n){
int[] f=new int[n+1];
f[0]=f[1]=1;
for(int i=2;i<=n;i++){
f[i]=f[i-1]+f[i-2];
}
return f[n]
}
在设计递归的程序时,我们要考虑:
1.递归的方法内部的所有非递归部分几乎都是在处理基准情况; 2.递归方法体内的递归调用就是归纳假设,因此在设计递归方 法时一定要明确方法的功能,并假设它能够调用实现应该的 功能。千万不要进入递归方法调用内部分析功能; 3.有些递归算法并不高效。
下面给出示例:
示例1
若要对n个不同的小写字母进行排列,并打印出所有可能的排列,请设计出算法实现它。
如:输入n=1以及“a”会得到“a”.输入n=2以及"ab",会得到“ab”和“ba”;“abc”则是“abc”,"acb","bac","bca","cab","cba"。通过观察我们发现,长度为n的排列都是在长度为n-1的排列前面加上另外一个字母而得,即n个字母的排列是这n个不同的字母与剩下的n-1个不同字母组成的所有排列组合的结果。
如n=3,n-1=2:
a:(abc,acb)
bc
cb
b:(bac,bca)
ac
ca
c:(cab,cba)
cab
cba
又如n=4,n-1=3:
d:(dabc,dacb,dbac,dbca,dcab,dcba)
abc
acb
bac
bca
cab
cba
a:(abcd,abdc,acbd,acdb,adbc,adcb)
bcd
bdc
cbd
cdb
dbc
dcb
b: ......
于是我们假设lists(String str,String head)是一个输入n个不同字母的其中一个排列字符串(如a,b,c的一个排列"abc")打印这n个不同字母的所有排列并且这些排列都额外加上了字符串head作为前缀的方法(在提醒一次,要先假设递归函数的功能,在调用时不要尝试进入递归方法体内部分析)。
public static void lists(String str,String head){
if(str==null)return;
if(str.length()<=1){
System.out.println(head+str);
return;
}
for(int i=0;i<str.length();i++){
lists(
str.substring(0,i)+str.substring(i+1,str.length()),
String.valueOf(head+str.charAt(i))
);
}
首先,本方法处理了待排列字符串的为空或长度为1的基准情况,如果这个字符串的长度不小于等于1,则对n个不同字母中的每一个,将它和剩下的n-1个不同字母的所有排列相组合,每一个有1*(n-1)!种排列,n个(次for循环)共n!个排列。注意要明确定义方法的功能,不要有模糊的地方,最后需要额外加上前缀head。
示例2
这是一个经典的问题,被称为汉萝塔问题。据古印度神话,在贝纳勒思的神庙里安放着一块铜板,版上竖插着三根银针,在其中的一根针上插着由小到大(越往上越小)的n片金片,现需要将这n片带插孔的金片移动到另一根针上,移动规则是一次只能将一个金片移动到另一根针上,并且在任何时候以及任何一根针上,小片只能放在大的上面。如n=3时,其中第一根针上有小到大、从上至下插着1、2、3三片金片,要将1、2,3都移动到第二根针上,将这三根针分别依次编号为A,B,C,则可以这样移动:
将金片1从A移动到B
将金片2从A移动到C
将金片1从B移动到C
将金片3从A移动到B
将金片1从C移动到A
将金片2从C移动到B
将金片1从A移动到B。
请设计程序打印出n片金片的移到过程。
很显然,当金片个数多于1时,仅仅凭着两根针是不能完成移动的,需要借助第三根针的帮助。根据由小及大的规则,我们先将n-1个小金片从针A移动到针C,让后将最大的那个金片从针A移动到针B,最后再将先前的n-1个金片从C移动到B。
我们定义方法moves(int n,char from,char to,char by)可以打印出将n片金片借助by从from移动到to的过程。则代码如下:
public static void moves(int n,char from,char to,char by){
if(n==1)
System.out.println("将金片"+1+"从"+from+"移动到"+to);
else {
moves(n-1,from,by,to);
System.out.println("将金片"+n+"从"+from+"移动到"+to);
moves(n-1,by,to,from);
}
}
//汉萝他问题——将编号为1~n的n个金片从第一根针A移动到第二根针B,并借助第三根针C
moves(4,'A','B','C');
在上面的代码中,if语句处理基准情况,else语句是三行语句,分别将n-1个小金片从针from移动到针by,让后将最大的那个金片从针from移动到针to,最后再将先前的n-1个金片从by移动到to。
示例3
下面内容含有一些介绍,不熟悉的读者可以在网上查阅相关资料。
树结构是递归定义的数据结构,一颗树T,要么为空集,要么至少包含一个节点,多于1的节点只间具有从这到那的矢量关系,起始的那个节点是父节点,被箭头指向的那个节点是父节点的子节点,它们是父子关系,是一种单向关系,每个子节点有且只有一个父节点。一个节点(这个节点就是树根,一个唯一在整个树中没有父亲的节点,且所有其它节点都是它的后代)下游若干棵子树,这些子树又是一颗树,它也有树根,它的树根是在它这棵子树中没有父节点的节点,而它又是整个大树的树根的子节点。
遍历一颗二叉树(它的每棵父节点只有两个子节点,且分左子节点和右子节点),我们可以先访问其根,然后再访问其左子节点和右子节点,这叫做先序遍历。
我们定义的二叉树如下:
class Tree {
Node root;
public Tree(Node root) {
this.root = root;
}
}
class Node{
String data;
Node left;
Node right;
public Node(String data) {
this.data = data;
}
public Node(String data, Node left, Node right) {
this.data = data;
this.left = left;
this.right = right;
}
}
我们定义方法visitTree(Node root)能够从树根root开始先序遍历以root为根的树,则代码如下:
public class TreeTest{
public static void visitTree(Node root){
if(root==null)return;
else {
System.out.println(root.data);
visitTree(root.left);
visitTree(root.right);
}
}
public static void main(String[] args) {
//初始化一个二叉树
Node f = new Node("F");
Node e = new Node("E",null,f);
Node d = new Node("D");
Node c = new Node("C",e,null);
Node b = new Node("B",d,null);
Node a = new Node("A",b,c);
Tree tree = new Tree(a);
visitTree(a);
}
以上代码,if语句处理基准情况,else语句递推,首先打印root.data访问根节点,然后分别从左子节点和右子节点开始遍历左子树和右子树。
示例4
下面,我们再举一个运用递归来设计算法的例子。 我们要求随机地构建一颗节点数为n的二叉树。若n==1,那么这颗树就是确定的,若n>1,那么我们可以递归地构建,先构建一颗左字树,再构建一颗右子树,它们的节点数分别为i和n-1-i,我们只需随机地指定左子树的节点数即可。我们规定方法
Node<Integer> randomTree(int n,int[] data)
用于随机地生成一颗 二叉树,它返回这颗二叉树的根节点,而方法中的参数data是为了标识每个节点,之所以是数组,是因为只有这样才能改变data中的值。下面代码只需递归调用即可。
/**
* 试图编写一个可以随机生成一颗二叉树的方法
*/
public static BinaryTree<Integer> randomTree(int n){
int[] data=new int[1];
return new BinaryTree(randomTree(n,data));
}
private static Node<Integer> randomTree(int n,int[] data){
if(n==0)return null;
if(n==1)return new Node<Integer>(data[0]++);
int m=new Random().nextInt(n);//产生随机数确定左子树节点数
Node<Integer> left=randomTree(m,data);
Node<Integer> right=randomTree(n-m-1,data);
return new Node<Integer>(data[0]++,left,right);
}
那么问题来了,到底有多少二叉树?解答在附录中。
示例5
为了看清楚所生成的树时随机的,你可以编写打印树的代码来观察。这又是一个递归的例子,我们可以指定树的深度来打印,代码如下:
static <E> void printTree(Node<? extends E> r,int depth){
if(r==null)return;
for(int i=0;i<depth;i++)System.out.print("\t");
System.out.println(r.data);
printTree(r.left,depth+1);
printTree(r.right,depth+1);
}
示例6
下面内容也含有一些介绍,不熟悉的读者可以在网上查阅相关资料。
为了弄清楚递归调用的一些机制,我们先来看看一个问题,它模拟了递归调用。
问题:这是一个平衡符号的问题,要求输入一串包含([{}])的字符串,判断这三种括号是否成对存在且不相互交叉,但可以互相独立地排列着,例如,([)]是交叉,是非法的,另外是包含与被包含、独立第排列:例如([])和()[]都是合法的。
恒明显这样的问题可以有栈来实现。代码如下:
/**
* 这是一个平衡符号算法,是栈的典型应用,同时也是对操作系统底层方法
* 调用的简单模拟。开放符号表示方法的加载和调用,封闭符号表示方法的
* 返回。
* @param str 是输入的一串包含[{()]}的字符串。
* @return true 如果[]、{}、()是成对存在的并且它们之间不交叉,
* 否则 返回 false
*/
public static boolean balance(String str){
Stack<Character> s = new Stack<>();
for (int i = 0; i < str.length(); i++) {
char c=str.charAt(i);
if(c=='(' || c=='[' || c=='{'){
s.push(c);
}else if(c==')' || c==']' || c=='}'){
if(s.isEmpty()){
return false;
}
else if(s.peek()=='(' && c==')')s.pop();
else if(s.peek()=='[' && c==']')s.pop();
else if(s.peek()=='{' && c=='}')s.pop();
else return false;
}
}
return s.isEmpty();
}
这是一个平衡符号算法,是栈的典型应用,同时也是对操作系统底层方法 调用的简单模拟。开放符号表示方法的加载和调用,封闭符号表示方法的 返回。
如果在一个方法中,调用了一个方法,那么就是包含与被包含,如果一些方法被按顺序地调用执行,呢么就是独立的排列的关系。读者可以在这里体会体会。
最后,附上了一些有关二叉树的代码作为参考。
附录
解答:,其中n是一棵二叉树的节点数。
代码:
package data_structures.trees;
import java.util.LinkedList;
import java.util.Random;
import java.util.Stack;
public class BinaryTree<E> {
Node<E> root;
public BinaryTree(){}
public BinaryTree(Node<E> root) {
this.root = root;
}
/**
* 依据后缀表达式来生成一颗相应的表达式树。
* @param expr 包含数字、二元操作符且用空格分隔的后缀表达式
* @return expr 的后缀表达式
*/
public static BinaryTree<String> createExprTree(String expr){
String[] arr = expr.split(" ");
Stack<Node<String>> stack = new Stack<>();
for (String s : arr) {
if(!s.equals("+") && !s.equals("-") && !s.equals("*") && !s.equals("/")){
stack.push(new Node<>(s));
}else {
Node<String> p;
switch (s){
case "+":
p = new Node<>("+");
p.right=stack.pop();
p.left=stack.pop();
stack.push(p);
break;
case "-":
p = new Node<>("-");
p.right=stack.pop();
p.left=stack.pop();
stack.push(p);
break;
case "*":
p = new Node<>("*");
p.right=stack.pop();
p.left=stack.pop();
stack.push(p);
break;
case "/":
p = new Node<>("/");
p.right=stack.pop();
p.left=stack.pop();
stack.push(p);
break;
}
}
}
return new BinaryTree<>(stack.pop());
}
private static class Node<E> {
E data;
Node<E> left;
Node<E> right;
boolean visited;
public Node(E data) {
this.data = data;
}
public Node(E data, Node<E> left, Node<E> right) {
this.data = data;
this.left = left;
this.right = right;
}
}
public void postPrint(Node<E> r){
if(r == null)return;
postPrint(r.left);
postPrint(r.right);
System.out.print(r.data+" ");
}
/**
* 二叉树的非递归后序遍历算法
*/
public static <E> void postPrint(BinaryTree<E> tree){
Node<E> root=tree.root;
LinkedList<Node<E>> q = new LinkedList<>();
if(root == null)return;
q.addLast(root);
while(!q.isEmpty()){
Node<E> cur=q.getLast();
if(cur.left != null && !cur.left.visited)
q.addLast(cur.left);
else if(cur.right != null && !cur.right.visited)
q.addLast(cur.right);
else {
cur.visited=true;
System.out.print(q.removeLast().data+" ");
}
}
}
/**
* 试图编写一个可以随机生成一颗二叉树的方法
*/
public static BinaryTree<Integer> randomTree(int n){
int[] data=new int[1];
return new BinaryTree(randomTree(n,data));
}
private static Node<Integer> randomTree(int n,int[] data){
if(n==0)return null;
if(n==1)return new Node<Integer>(data[0]++);
int m=new Random().nextInt(n);
Node<Integer> left=randomTree(m,data);
Node<Integer> right=randomTree(n-m-1,data);
return new Node<Integer>(data[0]++,left,right);
}
/**
* 非递归地打印二叉树
*/
// public void printTree(){
// class Tmp<E>{
// Node<E> node;
// boolean visited;
// }
//
// Stack<Tmp<E>> stack=new Stack<>();
//
// stack.push(root);
//
// while(!stack.isEmpty()){
// Tmp<E> cur=stack.peek();
//
//
// }
// }
static <E> void printTree(Node<? extends E> r,int depth){
if(r==null)return;
for(int i=0;i<depth;i++)System.out.print("\t");
System.out.println(r.data);
printTree(r.left,depth+1);
printTree(r.right,depth+1);
}
public static void main(String[] args) {
BinaryTree<Integer> t = randomTree(10);
printTree(t.root,0);
System.out.println();
t.postPrint(t.root);
System.out.println();
System.out.println();
postPrint(t);
}
}