算法设计技巧与分析 | 递归

829 阅读7分钟

递归

基本概念

让我们来举一个例子:数学中的阶乘运算:

image.png

在这个方程中,我们可以看到,当n为0时,结果为一个确定值1,当n≠0时,结果为与n有关的递推方程式。我们称上面一个式子为边界条件,下面一个式子为递归方程。而边界条件和递归方程是递归函数的两个要素,只有具备了这两个要素,递归函数才能在有限次计算后得出结果

image.png

让我们再来看几个例子加深一下对递归思想的理解吧!

斐波那契数列

斐波那契数列是一个有名的数列,该数列从第3项开始,每一项都等于前两项之和:1、1、2、3、5、8、13、21、34......

image.png

汉诺塔问题

设a、b、c是3个塔座。开始时,在塔座a上有n个圆盘,这些圆盘自下而上,由大到小地叠在一起。各圆盘从小到大编号为1、2、...、n,现要求将塔座a上的这一叠圆盘移动到塔座b上,并仍按同样顺序叠置。在移动圆盘时应遵守以下移动规则:

  • 规则1:每次只能移动一个圆盘;
  • 规则2:任何时刻都不允许将较大的圆盘压在较小的圆盘之上;
  • 规则3:在满足移动规则1和2的前提下,可将圆盘移动至a,b,c中任一塔座上。

image.png

总结一下,递归的优点是结构清晰、可读性强,而且容易用数学归纳法来证明算法的正确性,因此为算法设计带来了很大方便。但其缺点是递归算法的运行效率较低,无论是耗费的计算时间还是占用的存储空间都比非递归算法(迭代)要多。

递归和迭代

Q:所有的递归实现都可以转换为迭代实现吗?反过来呢?

A:

  • 迭代转化为递归通常较简单。

二分搜索

image.png

选择排序

image.png

插入排序

image.png

  • 递归转化为迭代时,要复杂很多。例如很难将汉诺塔问题转化为循环迭代。
  • 从实际上说,所有的迭代可以转换为递归,但递归不一定可以转换为迭代。

生成排序

如已知X = {1,2,3}的全排列P(x),Y = {1,2,3,4}的全排列P(Y),则P(X)和P(Y)之间存在什么关系?

4123 4132 4213 4231 4312 4321

1423 1432 2413 2431 3412 3421

1243 1342 2143 2341 3142 3241

1234 1324 2134 2314 3124 3214

因此,得出n个元素的集合Y = {y1, y2, ..., yn}的全排列的递归式为:

image.png

image.png

  • 算法的复杂度由if和else两部分决定,直觉上觉得是else起决定作用。
  • 实际上输出语句执行了nn!次,其中n!代表排序的总数,而每个排序有n个输出
  • 所以复杂度由if决定为是Θ(nn!)

image.png

(当n≥2时,for循环执行n次(+n),加上每一次递归调用函数(f(n-1)),所以执行次数总和为f(n) = nf(n-1) + n)

IMG_20221003_115517_edit_242162823733361.jpg

整数划分

一个正整数n可以表示成一个或多个正整数之和:n = n1 + n2 + ... + nk,其中n1 ≥ n2 ≥ ... ≥ nk ≥ 1,k ≥ 1。正整数n的这种表示称为正整数n的划分,显然,当n > 1时,n存在多种划分。整数划分问题就是求正整数n的不同划分的个数。

例如正整数6有以下11种不同的划分:

1+1+1+1+1+1;

2+2+2,2+2+1+1,2+1+1+1+1;

3+3,3+2+1,3+1+1+1;

4+2,4+1+1;

5+1;

6。

  • 下面的方法是否可行?

1 + (n - 1),2 + (n - 2),3 + (n - 3),...,[n/2] + [n/2]

则:

p(n) = p(1) + p(n - 1) + p(2) + p(n - 2) + ... + p([n/2]) + p([n/2])

存在重复计算

  • 我们换一种思路:对于任何一个正整数n,其本身就有一个边界条件是1 + 1 + ... + 1(n个1),我们用p(n,≤ 1)表示这个划分,也就是p(n,≤ 1)只包含小于等于1的划分的个数,即这些划分中所有的加数都小于等于1。这样,p(n,≤ 2)表示小于等于2的划分的个数,其由两部分组成:①小于等于1的划分p(n,≤ 1);②包含2的划分,用p(n,= 2)表示。即:
  • p(n,≤ 2) = p(n,≤ 1) + p(n,= 2)
  • p(n,≤ m) = p(n,≤ m - 1) + p(n,= m), m ≤ n
  • 其中p(n,= m)所代表的划分看成:image.png
  • 由此可得:p(n,= m) = p(n - m, ≤m)
  • p(n,≤ m) = p(n,≤ m - 1) + p(n - m, ≤m), m ≤ n
  • 整数划分的最终递归式为:image.png

时间复杂度

计算迭代次数

通常,程序的运行时间和程序的迭代次数成比例。因此计算程序的迭代次数就可以作为算法运行时间的指示器。这在很多算法中都可以得到应用,如查找、排序等等。

例:

image.png

分析:while迭代的次数是k + 1次(因为n ≥ 1可以写成n ≥ 2^0,运行过程n = 2^k -> 2^0),k = logn。在每次while循环里面for循环依次执行n,n/2,n/4,...,1次,所以,算法的时间复杂度为:

image.png

为什么在上面计算算法的时间复杂度时不考虑step6,而是只考虑step4呢?

如果同时考虑step4和step6,我们有

image.png

总结:使用计算迭代次数的技术来分析算法的时间复杂度时,只需要考虑最深层次的迭代。

频度分析

对于有些算法,计算迭代次数非常麻烦,有时甚至是不可能的。这个时候,可以使用频度分析。

如果算法中的一个元运算具有最高频度,所有其他元运算频度均在它的频度的常数倍内,则称这个元运算为基本运算。

例: 在MERGE算法中,赋值运算具有最大频度:

  • 将A的每个元素移到B(语句5和8)
  • 又将B的每个元素移到A(语句16) 所以算法复杂度为2n

最坏情况和平均情况分析

平均情况分析:通常考虑均匀分布情况下的复杂度。

如插入排序中,将第i个元素插入到前面已经排序好的数组中:

  • 插入到第1个位置,比较i-1次
  • 插入到其他位置(位置j),比较i-j+1次
  • 平均比较次数为: image.png
  • 插入排序总的平均比较次数为: image.png
  • 插入排序最差的比较次数为: image.png

复杂度的递归求解

在计算算法的复杂度时,经常会遇到算法的复杂度是一个递归表达式,所以需要求解这个递归式才能得出算法的复杂度。如对于某算法,设T(n)为输入规模为n的复杂度,其递归式为:

image.png

  1. 展开法:通过直接对递归的展开计算复杂度;
  2. 代入法:先猜测解的形式,再通过数学归纳法证明;
  3. 递归树方法:通过画递归树的方法来求解,因为这种方法适用性比较好,所以是最重要的方法;
  4. 主方法:针对T(n) = aT(n/b) + f(n)的形式,主方法给出了相关的规律,所以主方法是最简单的方法,但适用性较差。

展开法

展开法通过直接对递归的展开来计算复杂度,通常只能用于计算形式如T(n) = T(n - 1) + cn或者T(n) = T(n/2) + cn这种比较简单的递归式。

image.png

image.png

代入法

  • 步骤:先猜测解的形式,再通过数学归纳法证明;
  • 适用于对递归形式比较熟悉的情况;
  • 代入法另外一用法是对展开法或者递归树法求得的复杂度,进行进一步确认。

image.png image.png

image.png image.png image.png image.png

这个例子告诉我们,对于复杂度的证明,可以通过任意一种符合复杂度的假设进行归纳证明,如要证明T(n) = Θ(n^2),可以假设T(m) = cm^2(m < n,下同),也可以假设T(m) = cm^2 + bm,或者T(m) = cm^2 + bm + d都可,但归纳到T(n)时,必须是一致的

递归树方法

image.png image.png image.png image.png

image.png

主方法

image.png

image.png

image.png

image.png

该递归式用主方法求得的复杂度T(n) = Θ(nlogn),但对上式通过递归树方法得T(n) = Θ(n(logn)^2)。

几种递归形式的分析

image.png image.png image.png

image.png image.png image.png image.png