第六讲. 经典算法之递归与分治
1. 简介
递归与分治,顾名思义,就是既有递归又有分治。递归指函数调用自身,分治是指一个大的问题被分成了几个小问题,分而治之。总得来说,按我的理解,就是将一个具体的问题抽象成一类问题,在解决该具体问题时,将原问题逐个的分解成更小的问题,然后分别递归调用同一个函数来解决。
2. 从一个简单例题开始
回顾一下我们熟悉的斐波那契数列的计算: f ( n ) = { 1 n = 1 1 n = 2 f ( n − 1 ) + f ( n − 2 ) n > 2 f(n)=\begin{cases}1&n=1\cr 1&n=2\cr f(n-1)+f(n-2)&n>2\end{cases} f(n)=⎩⎪⎨⎪⎧11f(n−1)+f(n−2)n=1n=2n>2C语言递归代码如下:
int fib(int n)
{
if(n<=2)return 1; //递归函数出口
return fib(n-1) + fib(n-2);
}
显然,这里为了计算 fib(n) ,将之分解成了计算 fib(n-1) 和 fib(n-2) 并求和,这里的参数 n 可以看做是问题的规模,即一个大问题被分解成了两个小问题,而小问题又会被分解成两个更小的问题…… 直到递归函数的出口 n<=2 时直接返回结果 1 。没错,这其实就是一个简单的递归与分治的思想。
来对比一下for循环实现的版本:
int fib(n)
{
if(n<=2)return 1;
int a=1, b=1, ans;
for(int i=3;i<=n;i++)
{
ans = a+b;
a = b;
b = ans;
}
return ans;
}
对比之后不难发现,递归函数的代码十分简练而优雅,这也是递归与分治思想的特点之一,代码实现起来特别短小精悍!
3. 递归与分治真的好吗?
3.1 线性衰减的递推式
评断一个算法好不好,最直接的感受就是算得快不快。对上面两种斐波那契数列的计算方法通过几个输入样例进行测试,不难发现,随着 n 越来越大,到二三十甚至更大的时候,精炼的递归分治算法运行得尤为缓慢,甚至再大一点都跑不出结果了,而for循环实现的算法基本都是秒出结果,这是为什么呢?
试着分析分析两个算法的时间复杂度,for循环版本的斐波那契数列计算时间复杂度显然就是线性时间复杂度 O(n) ;而递归函数的时间复杂度并不是很直观,其实计算过程也不难,就是推导递归式,有点像高中数学里的已知递推方程求数列的通项公式。
为了表达方便,这里我们用 T(n) 表示计算一个规模为 n 的问题所需的时间(此处就是计算第 n 项斐波那契数列所花的时间),显然由已知条件可以知道,前两项斐波那契数列是已知的,不需要计算,即 T(1)=T(2)=0 ,即有递推方程:
T
(
n
)
=
{
0
n
<
=
2
T
(
n
−
1
)
+
T
(
n
−
2
)
+
1
n
>
=
3
T(n)=\begin{cases}0&n<=2\cr T(n-1)+T(n-2)+1&n>=3\end{cases}
T(n)={0T(n−1)+T(n−2)+1n<=2n>=3方程里的 “+1” 是因为每次计算 fib(n)=fib(n-1)+fib(n-2) 时需要作一次加法运算。
我们通过放缩法不难发现:
2
∗
T
(
n
−
2
)
+
1
<
T
(
n
)
<
2
∗
T
(
n
−
1
)
+
1
⇒
2
n
2
−
1
+
2
n
2
−
2
+
.
.
.
+
2
0
<
T
(
n
)
<
2
n
−
1
+
2
n
−
2
+
.
.
.
+
2
0
⇒
2
n
/
2
<
T
(
n
)
<
2
n
2*T(n-2)+1<T(n)<2*T(n-1)+1\\\Rightarrow 2^{\frac{n}{2}-1}+2^{\frac{n}{2}-2}+...+2^0 < T(n) < 2^{n-1}+2^{n-2}+...+2^0\\\Rightarrow 2^{n/2} < T(n) < 2^n
2∗T(n−2)+1<T(n)<2∗T(n−1)+1⇒22n−1+22n−2+...+20<T(n)<2n−1+2n−2+...+20⇒2n/2<T(n)<2n显然,不难发现,递归与分治实现的代码的时间复杂度达到了 O(2n) 级别的,已经远远超出 O(n) 的时间复杂度了,这也是为什么随着 n 变大,所花费时间变得很大,因为时间复杂度在以一个指数函数的形式增大。
一般而言,对于递归算法的分析,我们还有一个很重要的工具,就是递归树。简而言之,一个递归算法的运算过程,都可以由一颗递归树来描述。例如当我们计算 fib(5) 时,递归树如下:
不难发现,存在重复运算,例如 fib(3) 计算了两次,而且可以推断,随着 n 越大,重复运算会越多,而且在重复运算上花费的时间代价会越大,这也是为什么其时间复杂度比for循环实现差距这么大的原因。
一个较好的解决方法是在每次计算完 fib(i) 时,将结果保存在数组中,下次遇到 fib(i) 先判断其是否已经计算过,若是则直接取保存的结果,否则,递归计算,得出结果后保存至数组。这也是记忆数组的运用,或者说剪枝的方法,这样一来时间复杂度也将将至 O(n) 。
这么一来,难道递归与分治这么没用的吗?除了好看时间复杂度变大了这么多个数量级,那不成了绣花枕头?其实不然,递归与分治算法的好坏其实是跟递归推导式有关的,对于 T(n) = T(n-1)+T(n-2)+1 这种每次问题规模线性减小的情况确实是不适合递归与分治思想的,而如果是问题规模成比例降低,其作用就会大大体现出来了,我们接着往下看。
3.2 比例衰减的递推式
考虑计算一个数 a 的 n 次方,for循环版本如下:
int power(int a, int n)
{
ans = 1;
for(int i=1;i<=n;i++)ans*=a;
return ans;
}
显然,时间复杂度为 O(n)。
再考虑如下的递归实现版本:
int power(int a, int n)
{
if(n==0)return 1;
if(n==1)return a;
return a*power(a, n-1);
}
一如既往的简练,可是时间递推式 T(n) = T(n-1)+1 仍然是线性衰减,时间复杂度仍然为 O(n) ,虽然不够好,至少在时间复杂度不变的情况下代码变高大上了。
再考虑一个幂运算的规律:
a
n
=
{
a
n
2
∗
a
n
2
n
%
2
=
0
a
n
2
∗
a
n
2
∗
a
n
%
2
=
1
a^n = \begin{cases}a^{\frac{n}{2}}*a^{\frac{n}{2}} & n\%2=0\cr a^{\frac{n}{2}}*a^{\frac{n}{2}}*a & n\%2=1\end{cases}
an={a2n∗a2na2n∗a2n∗an%2=0n%2=1所以我们可以将递归代码实现成如下形式:
int power(int a, int n)
{
if(n==0)return 1;
if(n==1)return a;
int tmp = power(a,n/2) ;
if(n%2==0)return tmp*tmp;
else return tmp*tmp*a;
}
不难分析,时间递推式如下:
T
(
n
)
=
{
T
(
n
/
2
)
+
1
n
>
=
2
0
n
<
=
1
T(n)=\begin{cases}T(n/2)+1 & n>=2\cr 0 & n<=1\end{cases}
T(n)={T(n/2)+10n>=2n<=1不难求解出答案
T
(
n
)
=
O
(
l
o
g
2
(
n
)
)
T(n)=O(log_2(n))
T(n)=O(log2(n))这样一来,时间复杂度便从 O(n) 降成了 O(logn) ,实现了效率的提高。
所以,其实使用递归与分治关键是你如何拆分这个问题。而且有的问题可能无论如何拆分也不能较好的通过递归与分治降低时间复杂度甚至还会增大时间复杂度;而有的问题可能换一个角度思考就能利用某种规律实现问题规模成比例的下降,从而运用递归与分治思想实现时间效率的优化。
4. 最后说几句
其实无论什么算法而言,都没有更好最好之分,只有不适合与更适合之分,没有最好的算法,只有更适合的算法,具体使用什么算法我们需要根据具体问题来判断,使用最佳的算法解决问题才是我们需要实现的!
(关于斐波那契数列第n项的运算,有兴趣的可以了解了解利用矩阵的幂运算,然后再借助递归与分治实现,时间复杂度同样可以降到 O(logn) ,即矩阵快速幂)