我们举一个小例子
假如你跟你女朋友去电影院看电影,你不知道你的座位是几排,但是这个难不到你,你只需要问一下你前面的人是几排,然后你再+1就行了,但是你前面的人也不知道他是几排,你前面的人也向前问,知道问到第一排,他说我这是第一排,然后在一排一排的数据传回来,直到你前边的人告诉你他在几排,你就知道了答案
这就是一个非常标准的递归求解的过程,去的过程叫递,回来的过程叫归,基本上所有的递归问题,都可以用递归公式来表达,比如刚才的这个问题就可以用递归公式来表达
f(n)=f(n-1)+1 其中,f(1)=1
f(n)表示你想直到自己的排数,f(n-1)表示前面一排的排数,其中第一排知道自己在第一排f(1)=1,有了这个递归公式,我们可以把它改为代码
int f(int n) {
if (n == 1) return 1;
return f(n-1) + 1;
}
1 一个问题的解可以分为几个子问题的解
子问题就是规模更小的问题,比如刚才的问题,要知道自己在几排,就可以分解为,前面的人在几排就行
2 这个问题与分解之后的子问题,除了数据规模不通,求解思路都相同
比如上面的问题,求解“自己在几排”和前面的人求解“自己在几排”,思路是完全一样的
3 存在递归的终止条件
问题可以一层一层的分解下去,但是不能无限循环,所以需要终止条件
比如刚才的问题,第一排知道自己的位置是1,f(1)=1这就是终止条件
写递归代码的关键是,写出递归公式,找出终止条件,剩下把递归公式转换为代码就很简单了
我们看一个例子,尝试推导一下
假如有n个台阶,一次可以走1一个台阶,也可以一次走2个台阶,请问n个台阶一共有多少种走法?如何用编程求出有多少种走法?
我们来分析一下,首先第一步,这个大问题是否可以分解成几个小问题,我们可以把第一步的走法分为两类,1 第一步走了一个台阶,2 第一步走了2个台阶,那么n个台阶一共有多少种走法f(n),就等于先走1个台阶后f(n-1)中走法加上先走2个台阶后f(n-2)种走法,这个问题和子问题是不是除了数据规模不一样,思路都一样,我们可以看出是这样的,用公式表达
f(n) = f(n-1)+f(n-2)
有了递推公式,就完成了一半了,下面我们来看一下,是否存在终止条件,答案是有的,终止条件就是 f(1)=1;只剩一个台阶就只有一种走法,F(2)=2,只剩下俩个台阶,有俩种走法。总结起来的公式
f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2)
转换成代码
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
总结一下:写递归代码的关键就是找到如何把关键问题分解成子问题规律,并且基于此写出递归公式,然后在推敲出终止条件,最后把递归公式和终止条件翻译为代码
上边那个电影院的例子,我们的递归调用只有一个分支,也就是说“一个问题只需要分解为一个子问题”,我们很容易就可以想清楚递 和 归的每一个步骤,所以写起来,理解起来都不难
但是第二个台阶问题,一个问题要分解为多个子问题,人脑几乎不能把递和归的每一个步骤想清楚
计算机擅长做重复的事,所以递归正和他们的胃口,但是人脑喜欢平铺直叙的想事情,当看到递归时,就像把它平铺展开,脑子里循环,一步一步调用,试图搞清楚计算机是如何运行的
这样的话就掉进思维的误区,很多情况下我们理解起来比较吃力,那该如何正确的理解呢?
如果一个问题A 可以分解为子问题B C D,那么假设B C D 已经解决的情况下如何解决A,而且你只需要思考A和B C D的关系,不需要一层一层的向下思考,子问题和子子问题的关系。。。屏蔽掉递归细节,理解起来就会很简单
递归代码需要警惕堆栈溢出
我们知道,当一个函数执行时,会把临时变量压入栈中,直到函数执行完返回时,才会出栈,系统的栈或虚拟机的栈大都不怎么大,如果递归求解的数据规模很大,调用层次很深,一致压入栈,就会导致堆栈溢出
如何避免堆栈溢出?
我们可以限制调用的最大深度,来限制递归
// 全局变量,表示递归的深度。
int depth = 0;
int f(int n) {
++depth;
if (depth > 1000) throw exception;
if (n == 1) return 1;
return f(n-1) + 1;
}
递归代码要警惕重复计算
例如我们的第二个台阶问题
上面的f(3)就会重复计算,为了避免重复计算,我们可以把已经计算过的数据存入散列表中,每次计算先看下是否已经计算过
代码可以改为这样
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// hasSolvedList 可以理解成一个 Map,key 是 n,value 是 f(n)
if (hasSolvedList.containsKey(n)) {
return hasSovledList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSovledList.put(n, ret);
return ret;
}
比如第一个例子可以改为
int f(int n) {
int ret = 1;
for (int i = 2; i <= n; ++i) {
ret = ret + 1;
}
return ret;
}
第二个例子可以改为
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int ret = 0;
int pre = 2;
int prepre = 1;
for (int i = 3; i <= n; ++i) {
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
笼统的讲,递归代码都可以改为迭代循环的模式,一个是利用系统栈和虚拟机栈实现,一种为自己实现出栈和入栈,但是这种本质并没有变,没有解决上面的问题,还徒增复杂度
最后看一个比较难的递归问题
如何把 n个数据的所有排列打印出来?
比如现在有1 2 3 三个数据,有多少种排列?
他一共有下面几种排列
1, 2, 3
1, 3, 2
2, 1, 3
2, 3, 1
3, 1, 2
3, 2, 1
这个可以用递归实现
首先我们分析一下第一步是否可以分成几个子问题的解?
假如我们现在确定了最后一位的数据,那么问题就变成了n-1个数据有多少种排列的方式,最后一位可以是n中的任意一个,有n种取值,所以n个数据的排列方式可以分解为,n个n-1个数据的排列方式
如果我们把它写成地推公式
假设数组中存储的是 1,2, 3...n。
f(1,2,...n) = {最后一位是 1, f(n-1)} + {最后一位是 2, f(n-1)} +...+{最后一位是 n, f(n-1)}。
翻译成代码是这样的
// 调用方式:
// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k 表示要处理的子数组的数据个数
public void printPermutations(int[] data, int n, int k) {
//k==1 打印出来数据
if (k == 1) {
for (int i = 0; i < n; ++i) {
System.out.print(data[i] + " ");
}
System.out.println();
}
//最后一位固定
for (int i = 0; i < k; ++i) {
printPermutations(data, n, k - 1);
int tmp = data[i];
data[i] = data[k-1];
data[k-1] = tmp;
}
}