Backto Algo Index
定义 Definitions
递归(recursion)是一种编程技巧, 体现的是分治思想. 一个函数, 层层递
下去, 一直到最下面的终止条件
, 然后调头向上, 把结果一层一层再归
上来. 从而得到最终解.
递归是很多数据结构和算法的基础,
- 比如数据结构对应 栈(Stack)一层一层压下去,LIFO最后出来的就是最终结果,
- 算法中对应树, 比如二叉树的前中后序遍历.
一个可以使用递归算法求解的问题,需要满足三个条件:
- 一个问题可以分为为几个子问题的解. 子问题解决了, 大问题的解自然而然也就有了.
- 原问题与子问题, 解决思路一样, 只有数据规模的不同. 原问题的数据可以划分几个部分, 而每个部分可以再划分, 然后所有的划分都可以用相同的思路来求解.
- 存在递归终止条件. 不能无穷循环下去, 要有终止条件来停止问题向下
递
的过程, 然后开启向上把结果归
的过程.
写递归代码, 同上也是分三步走
- 分析问题, 写出递推公式
- 找到终止条件
- 翻译成代码
递归代码, 写完之后通常很好理解, 代码也很简介优美, 但是函数调用会使用栈来保存临时变量, 没调用一个函数, 都会将临时变量封装为栈帧压入内存栈, 等函数执行完成返回时, 才出栈. 如果递归求解数据规模很大, 调用层次很深, 一直 push 入栈, 就会有堆栈溢出的风险. 为了避免堆栈溢出, 通常的做法有:
- 设置递归深度, 超过此深度抛出异常.
- 优化流程, 去掉重复递归的步骤:因为分解的过程中有的过程会被计算多次. 不值得. 可记录成 HashTable
- 把递归代码改成非递归代码, 使用的方式就是用固定的几个变量, 从终止条件开始, 向上循环, 存储/迭代每次的结果, 直到到达目标值. 而且二者可以认为是等价的, 也就是说基本所有的递归都可以写成循环迭代的方式。因此,小数据量用递归OK,简洁优美。大数据量要改写成循环迭代的方式。
栗子
讲述递归,最常用的栗子就是求解斐波那契数列
long long Fibonacci(unsigned int n)
{
if(n <= 0)
return 0;
if(n == 1)
return 1;
return Fibonacci(n-1) + Fibonacci(n-2);
}
复杂度分析
Fibonacci 是讲解递归算法的最佳栗子,但是这并不代表递归算法是求解 Fibonacci 的最佳方法。分析一下时间复杂度和空间复杂度,就会发现递归解法实现起来就是一种灾难。
- 时间复杂度, 2 n 2^n 2n, 指数增长
- 空间复杂度, 2 n 2^n 2n, 极其容易爆栈
复杂度如此高的原因,重要的一点就是为了保证如此简洁优美的代码形式,里面包含列很多的重复计算和程序堆栈开辟。把这些浪费的资源去掉,把中间结果保存起来,防止多次运算。
O(n) 改进
long long Fibonacci(unsigned int n) {
if(n <= 0)
return 0;
if(n == 1)
return 1;
int result_k_minus_two = 0;
int result_k_minus_one = 1;
int result_k = 1;
for(int i=2; i <= n; ++i ) {
result_k = result_k_minus_one + result_k_minus_two;
result_k_minus_two = result_k_minus_one;
result_k_minus_one = result_k;
}
return result_k;
}
O(log n) 改进
Fibonacci 数列的求解,用矩阵的形式表达出来就是
[ f ( n ) f ( n − 1 ) f ( n − 1 ) f ( n − 2 ) ] = [ 1 1 1 0 ] n − 1 \begin{bmatrix}f(n)&f(n-1)\\f(n-1)&f(n-2)\\ \end{bmatrix} = \begin{bmatrix}1&1\\1&0\\ \end{bmatrix}^{n-1} [f(n)f(n−1)f(n−1)f(n−2)]=[1110]n−1,
而计算右边的矩阵的 k k k次方,可以使用 a n = ( a n / 2 ) 2 a^n = (a^{n/2})^2 an=(an/2)2 来减小一倍运算量,如此迭代,复杂度降为 O ( l o g n ) O(log n) O(logn)
O(1) 改进
就是查表解决啦,把常用的 Fibonacci 数列用一个 array 存储起来,那么每次调用的时候直接 array[n]
就出来了。 比如说存储前 100 个数,0 ~ 99
都是直接查表,从 100
开始再用前面
O
(
n
)
O(n)
O(n) 的算法求解。这样,如果 99.99 % 的调用都是可以直接查表的话,那么这个算法可以认为是平均
O
(
1
)
O(1)
O(1)的
Fibonacci 数列的另外一个变种问题就是, 青蛙跳台阶问题. 一直青蛙在井底, 井底到地面有 n n n 个台阶, 青蛙可以一次跳一格, 也可以一次跳两格, 那么请问青蛙有多少种方式跳到地面?
首先分解问题, 青蛙起始的状态是井底, l a y e r = 0 layer=0 layer=0, 目标是跳到 l a y e r = n layer = n layer=n, 而中间过程 l a y e r = k layer = k layer=k 的求解和 l a y e r = n layer=n layer=n 是完全一样的. 因此, 可以考虑用递归求解.
然后, 用递归的思路重新分析问题, 假设青蛙已经上来了 l a y e r = n layer=n layer=n, 那它上一步有几种情况呢? 根据题意, 有两种, 一是从 n − 1 n-1 n−1 上跳 1 1 1格上来, 二是从 n − 2 n-2 n−2上跳2格上来. 只有这两种情况, 没有其他情况了. 然后我们在分析, n − 1 n-1 n−1, n − 2 n-2 n−2 这些台阶也都是有两种情况达到, 下面两阶跳两格上来或者下面一阶跳1 格上来. 那么终止条件是什么呢? 就是 l a y e r = 2 layer = 2 layer=2 的时候, 有两种情况, 一步一格两步上来或者一步两格直接上来. l a y e r = 1 layer = 1 layer=1 的时候, 只有 1 种情况, 就是一步走了一格. 所以, 递推公式和终止条件就是 f ( n ) = { f ( n ) = f ( n − 1 ) + f ( n − 2 ) f ( 2 ) = 2 f ( 1 ) = 1 f(n) = \begin{cases} f(n) = f(n-1)+f(n-2) \\ f(2)=2\\f(1)=1 \end{cases} f(n)=⎩⎪⎨⎪⎧f(n)=f(n−1)+f(n−2)f(2)=2f(1)=1
翻译成递归代码就是,
int GetSteps(int n) {
if(n <= 0) return 0;
if(n == 1) return 1;
if(n == 2) return 2;
return GetSteps(n-1) + GetSteps(n-2);
}
简单画一下图就会发现, 求解 f ( 6 ) f(6) f(6) 会计算 f ( 5 ) & f ( 4 ) f(5) \& f(4) f(5)&f(4), 而计算 f ( 7 ) f(7) f(7) 会计算 f ( 6 ) & f ( 5 ) f(6) \& f(5) f(6)&f(5), 很明显 f ( 5 ) f(5) f(5) 计算了两次, 可以通过存入HashTable 来避免重复计算
int GetSteps(int n) {
if(n <= 0) return 0;
if(n == 1) return 1;
if(n == 2) return 2;
if(hasSolvedSet.containsKey(n))
return hasSolvedSet.get(n);
int ret = f(n-1) + f(n-2);
hasSolvedSet.put(n, ret);
return ret;
}
当然这还不够彻底, 更彻底的方式是直接去掉递归形式, 仅保留递归的想法.
int GetSteps(int n) {
if(n <= 0) return 0;
if(n == 1) return 1;
if(n == 2) return 2;
int ret = 0;
int pre1step = 2;
int pre2step = 1;
for(int i = 3; i <= n; ++i) {
ret = pre1Step + pre2step;
pre2step = pre1step;
pre1step = ret;
}
return ret;
}
Ref
- 剑指Offer – By 何海涛 : 代码例子
- 数据结构与算法之美–By 王争 : 理论总结