递归算法对于计算机相关领域的同学并不默认,它以实现代码简洁著称,但同时它的可读性又比较差,让人又爱又恨,本文对递归相关的东西做一个小结。
递归的一般模式
stackoverflow上有人总结了一个模型,我觉得可以很好的说明递归程序设计的一般模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
Result M(Problem prob)
{
if (<problem can be solved easily>)
return <easy solution>;
// The problem cannot be solved easily.
Problem smaller1 = <reduce problem to smaller problem>
Result result1 = M(smaller1);
Problem smaller2 = <reduce problem to smaller problem>
Result result2 = M(smaller2);
...
Result finalResult = <combine all results of smaller problem to solve large problem>
return finalResult;
}
顺便给了一个求树的高度的例子:
1
2
3
4
5
6
7
8
9
10
11
int Depth(Tree tree)
{
// Start with the trivial case. Is the tree empty?
if (tree.IsEmpty) return 0;
// The tree is not empty.
// Reduce the problem to two smaller problems and solve them:
int depthLeft = Depth(tree.Left);
int depthRight = Depth(tree.Right);
// Now combine the two solutions to solve the larger problem.
return Math.Max(depthLeft, depthRight) + 1;
}
递归程序的特点
所有的递归程序都具有以下特点:
- 这些方法使用if-else或switch语句来引导不同的情况
- 一个或多个基础情况(最简单的情况)用来终止递归
- 每次递归都会简化原始问题,让它不断接近基础情况,直到它变成基础情况(最小问题)为止
- 一个复杂的问题的解决需要依赖一个或多个子问题的解决,并根据子问题的解决结果来决定当前问题如何解决
函数调用、理解递归
对于程序,编译器会对其分配一段内存,在逻辑上可以分为代码段,数据段,堆,栈:
- 代码段:保存程序文本,指令指针EIP就是指向代码段,可读可执行不可写
- 数据段:保存初始化的全局变量和静态变量,可读可写不可执行
- BSS:未初始化的全局变量和静态变量
- 堆(Heap):动态分配内存,
向地址增大的方向增长
,可读可写可执行 - 栈(Stack):存放局部变量,函数参数,当前状态,函数调用信息等,
向地址减小的方向增长
,可读可写可执行
对于递归来说,栈的概念非常重要:先进后出
,如果当前函数执行到需要依赖其他函数的返回结果时,会被先压入栈,等下一层函数返回结果后,再被弹出栈,继续运行。
经典递归问题
1、回文字符串判断
验证指定的字符串是否为回文,如「abcdedcba」
迭代
for循环的做法:依次判断字符串的收尾,start和end索引位置的字符是否相同,相同则start++,end–,依次判断,直到start==end或start>end,证明是回文。 这里不做赘述。
递归
递归的做法:跳出条件start>=end
时直接返回true,否则判断start和end位置的字符,再递归调用方法前注意调整两个index的位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static boolean isPalindrome( String str ){
//重载调用,并且左边参数从下标0开始,右边参数从字符串尾端的位置开始
return isPalindrome( str, 0, str.length() - 1 );
}
public static boolean isPalindrome( String str, int beginIndex, int tailIndex ){
if( tailIndex <= beginIndex ){
//如果到达中心点,表明前面的所有字符都两端相等,那么就返回true
return true;
} else if( str.charAt( beginIndex ) != str.charAt( tailIndex ) ){
//如果有字符串不相等,那么就返回false
return false;
} else{
//否则,表明并没有到达中心点,但这次下标的两个字符相等,继续判断下一个
//左边下标指向后移,右边下标指向前移
return isPalindrome( str, beginIndex+1, tailIndex-1 );
}
}
这里使用了
递归辅助方法
,通俗来说,递归辅助方法就是通过对方法的重载,给原方法参数基础上增加额外的参数,使在调用的时候通过重载达到我们的需要。
如果不使用递归辅助方法,使用一个参数作为函数声明,则代码如下:
1
2
3
4
5
6
7
8
9
10
11
public static boolean isPalindrome(String str) {
if ( str.charAt(0) != str.charAt(str.length() - 1) ) {
return false;
}
if (str.length() <= 1) {
return true;
}
return isPalindrome(str.substring(1, str.length() - 1));//切割两端,将子串递归调用
}
方法参数省去了两个独立的 index
变量,使得计算完全依赖于参数 str
,以及它的长度,这样做的一个坏处是,在 Java
里String#substring()
方法会每次生成一个新的字符串,一共需要对比 len/2
次,即一共产生 len/2
个子字符串,当原始字符串较长时,会浪费很多内存空间。
2、Palindrome Number
这里和1的区别是数字,不能使用字符串相关的特性,如下标来计算,只能用数学计算。
通过取整和取余操作获取整数中对应的数字进行比较。
举个例子:1221 这个数字。
通过计算 1221 / 1000, 得首位1
通过计算 1221 % 10, 可得末位 1
进行比较
再将 22 取出来继续比较
递归
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static boolean isPalindrome(int x) {
int n = 0, div = 1;
while(x / div >= 1) {
div *= 10;
n ++;
}
return isPalindrome(x, n);
}
public static boolean isPalindrome(int x, int len) {
// 边界判断
if (x < 0) return false;
if (len == 1 || len == 0) return true;
int div = 1;
int newLen = len - 2;
while (--len > 0) div *= 10;
int left = x / div;
int right = x % 10;
if (left != right) return false;
x = (x % div) / 10;
return isPalindrome(x, newLen);
}
非递归
使用while
进行迭代,每一轮都比较当前数字的 头
和 尾
的两个数值,不相同直接返回 false
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static boolean isPalindrome(int x) {
// 边界判断
if (x < 0) return false;
int div = 1;
// 需要首先算出该数字除了最高位外的位数包含几个0
while (x / div >= 10) div *= 10;
while (x > 0) {
int left = x / div;
int right = x % 10;
if (left != right) return false; // 短路
// x % div,同位数之间的数字取模,余数是去掉最高同位数的数字的后面的部分
// 即 983 % 100 = 83
x = (x % div) / 10;
div /= 100; // 收尾比较完毕后下一轮比较,需要去掉两个0
}
return true;
}
下面这种写法只能在全部转换完毕后才能知道是否是回文,因为它是按照从尾部开始往前构建一个反转数字的:
1
2
3
4
5
6
7
8
9
10
11
12
boolean isPalindrome(int x) {
if(x < 0) return false;
int r = 0, t;
t = x;
while(t != 0)
{
r = r * 10 + t % 10;
t /= 10;
}
return r == x;
}
3、Reverse Integer
Reverse digits of an integer.
Example1: x = 123, return 321 Example2: x = -123, return -321
思路1:用long
为了防止溢出,可以考虑使用 long
进行计算中间结果:
1
2
3
4
5
6
7
8
9
10
11
12
int reverse(int x) {
long tmp = 0;
while(x != 0)
{
tmp *= 10;
tmp += x % 10; /* 这两行可以简写成 tmp = tmp * 10 + x % 10 */
if(tmp > INT_MAX || tmp < INT_MIN)
return 0;
x /= 10;
}
return tmp;
}
思路2:变化前后对比
如果溢出了,那溢出后的值做反向操作会和之前的值不一样的,可以直接校验得出结论:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int reverse(int x) {
int result = 0;
while (x != 0)
{
int tail = x % 10;
// 注意这一步可能溢出
int newResult = result * 10 + tail;
// 将上一步的算式反向计算一遍看是否能得到和result相同的结果,不可以就是溢出了
if ((newResult - tail) / 10 != result)
{
return 0;
}
result = newResult;
x = x / 10;
}
return result;
}
思路3:提前停止操作
如果当前的数已经>INT_MAX/10
, 那么再做一次操作,必然溢出。反之也是一样,如果当前的数已经 <INT_MIN/10
,那么再做一次操作也必然会溢出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int reverse(int n)
{
int result = 0;
while (n != 0)
{
if (result > INT_MAX / 10
|| ((result == INT_MAX / 10) && (n % 10 > INT_MAX % 10)))
{
result = 0;
break;
}
if (result < INT_MIN / 10
|| ((result == INT_MIN/ 10) && (n % 10 < INT_MIN % 10)))
{
result = 0;
break;
}
result = result * 10 + n % 10;
n = n / 10;
}
return result;
}
4、Symmetric Tree
例如,二叉树 [1,2,2,3,4,4,3] 是对称的。
1 2 3 1 / \2 2
/ \ / \
3 4 4 3
例如,二叉树 [1,2,2,nil,4,4,nil] 也是对称的。
1 2 3 1 / \2 2
1 2 3 4 5 6 7 8 9 10 11 12 \ / 4 4 用递归做比较简单:一棵树是对称的等价于它的左子树和右子树两棵树是对称的,问题就转变为判断两棵树是否对称。 ```java public boolean isSymmetric(TreeNode root) { if(root == null) return true; // 把问题变成判断两棵树是否是对称的 return isSym(root.left, root.right); } //判断的是根节点为r1和r2的两棵树是否是对称的 public boolean isSym(TreeNode r1, TreeNode r2){ if(r1 == null && r2 == null) return true; if(r1 == null || r2 == null) return false; // 这两棵树是对称需要满足的条件: // 1.俩根节点相等。 2.树1的左子树和树2的右子树,树2的左子树和树1的右子树都得是对称的 return r1.val == r2.val && isSym(r1.left, r2.right) && isSym(r1.right, r2.left); } ```
本文首次发布于 LiuShuo’s Blog, 转载请保留原文链接.