《高阶Perl》——1.8 当递归膨胀时

1.8 当递归膨胀时

有时一个问题很明显是递归,然后递归方案很没有效率。一个非常简单的例子是计算Fibonacci数。这是个想象的例子,但它的优点就是非常简单。我们将在3.7节看到一个更实际的例子。

1.8.1 Fibonacci数

Fibonacci数(Fibonacci number)以Leonardo of Pisa 命名,他的别名是Fibonacci,他在13世纪联系一个关于兔子的数学问题探讨了它们。起初,你有一对兔宝宝。兔宝宝一个月就发育成年了,下一个月它们生育了一对新的兔宝宝,这样有两对了:

月份 兔宝宝的对数 成年兔的对数 总的对数

  1 1   0   1
  2 0   1   1
  3 1   1   2

再下一个月,兔宝宝发育,成年兔又生育了一对新的兔宝宝:

  4 1   2   3

之后一个月,兔宝宝发育,两对成年兔各生育一对兔宝宝:

  5 2   3   5

假设没有兔子死亡,且继续生育,那在每个月共有多少对兔子呢?

假设A(n)是第n月的成年兔的对数,B(n)是第n月的兔宝宝的对数。第n月的兔子的总对数,称为T(n),因此是A(n)+B(n)。

T(n)=A(n)+B(n)

不难看出某个月的兔宝宝的对数与上个月的成年兔的对数相等,因为每对成年兔生育一对兔宝宝。用符号表示就是 B(n)=A(n-1)。代入上面的公式:

T(n)=A(n)+A(n-1)

每个月的成年兔子的数量等于上个月的所有兔子的数量,因为上个月的兔宝宝长大了,成年兔还存活着。用符号表示就是A(n)=T(n-1),代入上面的等式:

T(n)=T(n-1)+T(n-2)

因此第n个月的兔子总数是第n-1月与第n-2月的兔子数量的总和。有了这个公式,就可以写下计算Fibonacci数的函数:

### Code Library: fib
# Compute the number of pairs of rabbits alive in month n
sub fib {
  my $n = shift;
  if ($n < 2) { return $n }
  fib($n-2) + fib($n-1);
}

这是非常直接的,但有个问题:除非是小的参数,否则就会一直运行。举个例子,如果你要算fib(25),它需要递归调用计算fib(24)和fib(23)。但是调用fib(24)也要递归调用fib(23),另一个调用也要计算fib(22)。两次fib(23)调用都要调用fib(22),一共三次。结果是fib(21)计算5次,fib(20)计算8 次,fib(19)计算13次。

所有这些计算与重复的计算都会带来沉重的代价。在我的小计算机上,花费了大约4秒计算fib(25),其间有242 785次递归调用。花费了大约6.5秒计算fib(26),其间有392 835次递归调用,大约10.5秒进行635 621次递归调用计算fib(27)。 计算fib(27)的时间与计算fib(25)和计算fib(26)的总和时间差不多,因此函数的运行时间快速地增加,参数每增加2,时间将翻倍更多。

运行时间真的很快就无法承受了,这都是由于重复计算了已经计算过的东西。递归函数偶尔会有这样的问题,但是有一个简单的解决方案,将在第3章介绍。

1.8.2 划分

Fibonacci数有点深奥,也很难找到需要计算它们的简单而现实的程序实例。

这里有一个多少有点更现实的例子。有一些值钱的物品,称为“宝物”,想把它们平均分给两个人。知道每个物品的价值,希望确保每个人得到的物品的总价值相等。或者,把问题改得更平凡一点:知道你今天购买的每样杂货的重量,由于你准备一手一个袋子把它们都提回家,你期望平均分配重量。

为了使你相信这个问题是一个需要技巧的问题,试试划分一组10件物品,它们具有这些美元价值:

       $9,  $12, $14, $17, $23, $32, $34, $40, $42, $49

由于物品的总价值是$272,因此每个人将必须得到总价值为$136的物品。然后试试:

       $9, $12, $14, $17, $23, $32, $34, $40, $38, $49

这里我用$38的物品替换了$42的物品,因此每人将必须得到总价值$134的物品。

这个问题称为划分问题(partition problem)。把问题稍微概括一下:与其尝试把一堆宝物分成两等分,我们不如将尝试找到总价值是给定数目的那些宝物。平均分配宝物与找到一个价值是所有宝物总价值一半的份额是一样的,另一份额是剩余的宝物,它的总价值是相同的。

如果没有一份宝物的总价值等于目标数目,函数将返回undef:

### Code Library: find-share
sub find_share {
  my ($target, $treasures) = @_;
  return [] if $target == 0;
  return    if $target < 0 || @$treasures == 0;

先关注一些普遍的情形。如果目标数目正好是零,那么很容易产生一列宝物其总价值符合目标数目:空列表肯定具有零价值,所以立刻返回它。

如果目标数目小于零,就不可能得到结果,因为假设宝物有正的价值。在这种情况下没有答案,函数立即返回失败。如果没有宝物,就知道无法达到目标数目,因为已经知道目标数目比零大,所以立刻失败了。

否则,若目标数目是正的,就将不得不做些实际工作了:

  my ($first, @rest) = @$treasures;
  my $solution = find_share($target-$first, \@rest);
  return [$first, @$solution] if $solution;
  return         find_share($target       , \@rest);
}

这里复制宝物的列表,然后移走列表的第一个宝物。这是因为将考虑更简单的问题,即如何划分没有了第一个宝物的宝物堆。有两种可能的划分:第一个宝物在将要计算的份额内,或者不在。如果它在,那么必须在剩余的宝物中找到一个子集,其总价值是$target-$first。如果它不在,那么必须在剩余的宝物中找到一个子集,其总价值是$target。剩余的代码递归调用find_share调查这两种情况。如果第一种方案可行,函数返回一个包括第一个宝物的方案;如果第二种方案可行,它返回一个忽略第一个宝物的方案;如果没有一种解出,它返回undef。

这里是一个实例运行的轨迹。调用find_share(5, [1, 2, 4, 8]):

目前的份额 目前的总数 目标 剩余的宝物

    0   5    1  2  4  8

普通的情形没有发生,目标数目既不是负数也不是零,现存的宝物列表也不是空的。因此函数试着分配第一项,1,到份额里,然后它在剩余的项中寻找一组能满足总和为4:

1   1   4   2  4  8

函数将继续考察这种情况直到它被迫放弃。

然后函数分配剩余的第一项,2,到期望值是4的份额里,并递归调用以在最后的两个元素中寻找一组能满足总和为2:

1  2    3   2   4  8

这个称为“a状态”。函数将继续考察这个状态直到它断定a状态是没希望的。它尝试分配4到份额里,但是那超出了目标总数:

1 2 4 7 -2 8

所以它退回去并尝试从状态a继续,但不把4分配到份额里:

1 2 3 2 8

份额还没完成,所以函数分配下一项,8,到份额里,这明显超出了目标总数:

1 2 8 11 -6

这里得到$target < 0,所以函数失败了,尝试忽略8。这还是不行,它得到的份额距离目标数目还差2,没有剩余的项可以分配了。

目前的份额 目前的总数 目标 剩余的宝物

1  2     3  2

这就是if (@$treasures == 0) { return undef }情形。

函数已经尝试了使状态a成功的所有可能,都失败了。它断定:分配了1和2到份额里行不通,然后退回去尝试忽略2:

1   1   4   4  8

现在它尝试分配4到份额里:

1  4    5   0    8

现在函数得到$target == 0,所以它返回成功。分配的宝物是1与4,相加的总和是目标5。

这个思路是忽略第一个宝物然后在剩余的宝物里寻找答案,这样自然地把问题化简到了一个更简单的情形。一个没有递归的方案也许可以通过复制递归方案的底层机制,并人为模拟函数调用栈,而得到结果。

现在解决划分问题简单了,它是对find_share()的一个调用,找到第一份,然后以一些额外的工作计算原始数组中不包含在第一份里的元素:

### Code Library: partition
sub partition {
  my $total = 0;
  my $share_2;
  for my $treasure (@_) {
    $total += $treasure;
  }
  my $share_1 = find_share($total/2, [@_]);
  return unless defined $share_1;

首先函数计算所有宝物的总价值。然后它调用find_share()在原始的宝物中计算出一个子集的总价值恰好是一半。如果find_share()返回一个未定义值,那就没有相等的划分,所以partition()立即返回失败。否则,它将开始计算不在$share_1中的宝物列表,这就将是第二份:

  my %in_share_1;
  for my $treasure (@$share_1) {
    ++$in_share_1{$treasure};
  }

  for my $treasure (@_) {
    if ($in_share_1{$treasure}) {
      --$in_share_1{$treasure};
    } else {
      push @$share_2, $treasure;
    }
  }

函数使用一个散列汇总每个值在第一份中出现的次数,然后在原始列表中每次查看一个宝物。如果它看到一个宝物已经在第一份里了,它就扣除;否则,它就把宝物放进宝物列表组成第2份。

  return ($share_1, $share_2);
}

当它完成时,它返回两个宝物列表。

这里有许多代码,但大部分是为了划分一列数字的。关键是调用find_share()这一行,它实际上计算答案,即$share_1。剩余的代码都产生一个不在$share_1里的宝物的列表,即$share_2。

然而,函数find_share有个问题:它运行得太久了,特别是如果没有答案。问题本质上和fib的一样:它一遍又一遍地重复了同样的工作。例如,假设它在尝试找到1 2 3 4 5 6 7的一个目标总数是14的划分。它可能考察含有1和3的份额,然后看看能否以5 6 7得到目标总数是10。这不可能,所以它将寻找其他答案。后来,它可能考察含有4的份额,然后又去看看能否以5 6 7得到目标总数是10。这是在浪费时间,find_share应该在第一次考察后记住5 6 7不可能得到目标总数10。

第3章将介绍如何修正这个问题。

时间: 2024-10-28 05:45:43

《高阶Perl》——1.8 当递归膨胀时的相关文章

《高阶Perl》——导读

前 言 在编程圈子里有一句著名的俗语,一个优秀的Fortran程序员可以用任何语言写Fortran程序.然而,让人悲哀的是,不管他们是否愿意,Fortran程序员用任何语言写Fortran程序.类似地,作为Perl程序员,我们也在用Perl写C程序,不管我们是否愿意.这让人羞愧,因为Perl是一门比C更富有表现力的语言.我们本可以做得更好,以C程序员梦想不到的方式使用Perl,但是我们没有那样做. 怎么会这样呢?Perl的设计初衷是一方面作为C的替代品,另一方面作为UNIX脚本语言(如Bourn

《高阶Perl》——3.6 CAVEATS

3.6 CAVEATS (这是拉丁文的"警告".) 显然,记忆术不适合所有的性能问题.它甚至不适合所有的函数.有几类函数就不应该带记忆. 3.6.1 返回值不依赖参数的函数 记忆术最适合那些返回值只依赖它们的参数的函数.想象一下使时间函数带记忆的愚蠢:第一次你调用它,你将得到时刻,随后的调用将会返回一样的时刻.类似地,想象一个带记忆的随机数生成器是多么固执. 或者想象一个返回值是指示某类成功或失败的函数.你不会希望这类函数是带记忆的,每次被调用都返回同一个值. 然而,记忆术适合一些这样

《高阶Perl》——3.1 缓存修正递归

3.1 缓存修正递归 在1.8节看到递归函数有时耗时太长了,即便对简单的输入,那个Fibonacci函数就是这个问题的一个例子: # Compute the number of pairs of rabbits alive in month n sub fib { my $n = shift; if ($n < 2) { return $n } fib($n-2) + fib($n-1); } 正如在1.8节看到的,这个函数对多数参数都运行缓慢,因为它在重复计算那些已经计算过的结果上浪费时间.例

《高阶Perl》——3.2 内联缓存

3.2 内联缓存 给一个函数添加缓存的最直接的方式就是给函数一个私有的散列.在这个例子里,可以使用一个数组代替散列,因为fib()的参数总是一个非负整数.但是一般需要使用一个散列,那么将会看到: ### Code Library: fib-cached # Compute the number of pairs of rabbits alive in month n { my %cache; sub fib { my ($month) = @_; unless (exists $cache{$m

《高阶Perl》——3.5 MEMOIZE模块

3.5 MEMOIZE模块 本书不是关于Perl模块的内部细节的,而是关于一些Memoize模块内部使用的并和稍后要做的事情有直接联系的技术,所以现在简短地介绍一下. Memoize得到一个函数名(或引用)作为它的参数.它制造一个新的函数,后者维护一个缓存并在其中查找它的参数.如果新的函数在缓存里找到了参数,就返回缓存的值:如果没找到,就调用原始函数,把返回的值保存入缓存,并把它返回给原始的主调者. 制造完这个新的函数,Memoize就把它装入Perl的符号表以代替原始的函数,那样当你认为你在调

《高阶Perl》——3.7 键的生成

3.7 键的生成 先前的记忆器至少有一个严重的问题.它需要把函数的参数转变成一个散列键,它的做法是使用join: my $key = join ',', @_; 这对只带一个参数的函数有效,也对参数不含逗号的函数有效,包括所有的参数是数字的函数.但是如果函数的参数可能包含逗号,它可能失效,因为以下两个调用却计算出了相同的键: func("x,", "y"); func("x", ",y"); 第一次调用,返回的值将以键&quo

《高阶Perl》——1.2 阶乘

1.2 阶乘 假设有一个有n个不同条目的列表.为了具体点,假设这些条目是字母表的字母.这样一个列表有多少种不同的排列次序呢?显然,因为答案依赖于n,所以它是n的函数.这个函数称为阶乘函数(factorial function).n的阶乘就是n个不同条目的不同的排列次序的数量.通常以一个后缀!标记,这样n的阶乘就是n!.一般不同的次序称为排列(permutation). 下面计算一些阶乘.显然,只有一个条目的列表只有一种排列,所以1!=1.两个条目的列表有两种排列:A-B和B-A.少许笔算可以发现

《高阶Perl》——3.12 速度的好处

3.12 速度的好处 此刻有个说法比较诱人,那就是记忆术只比手动缓存技术改善了一点,因为它做了同样的事情,仅有的额外的好处是你可以快速启动或者关闭它.但这不完全正确.当工具之间的速度和便利的差别够大,它会改变你思考和使用工具的方式.自动地使一个函数带记忆仅需要消耗手动书写代码的1/100的时间.这和飞机与牛车的速度差别一样.说飞机只是更快的牛车就忽略了本质:量变如此巨大以致它也变成了实质的质变. 例如,有了自动的记忆术,就有可能为函数增加缓存行为而不必预先仔细考虑性能细节.记忆术这么简单以至于可

《高阶Perl》——1.4 层次化数据

1.4 层次化数据 已经介绍的例子显示了递归过程的风采,但是它们遗漏了重要的一点.在介绍汉诺塔问题时,我说过当你想解决的问题可以简化成同样问题的更简形式时,递归是很有用的.但是这样的问题是普遍的这一点可能不清楚. 多数递归的函数是为处理递归的数据结构的建立.一个递归的数据结构就像一个列表.一棵树或者一个堆,它们根据相同数据结构的更小的实例定义而成.最常见的例子可能是文件系统目录结构.文件可以是: 文件可以是一个目录,含有一列文件,其中的一些可以是目录,再含有另一些文件,以此类推.处理这样的结构最