Perl:使用尾递归优化递归地查找数组的总和

4

我试图编写一个尾递归函数。

sub sum {
    my ($first, @rest) = @_;

    return @rest
        ? $first + sum(@rest)
        : $first;
}

say sum(1 .. 100);

这段代码在处理100个元素时可以运行,但处理100_000个元素时会出现“内存不足”的错误信息。

如何改进代码以使递归算法能够处理更多元素?

编辑

上述函数的尾调用优化版本:

use feature qw( current_sub );

sub sum_tco {
    my $loop = sub {
        my ($sum, $first, @rest) = @_;
        $sum += $first;

        return @rest
            ? __SUB__->($sum, @rest)
            : $sum;
    };

    return $loop->(@_);
}

看起来Perl 5不支持TCO。

如果可能的话,如何在Perl中实现TCO?


那不是一个尾递归函数,而且如果我没记错的话,Perl 没有进行尾调用优化,除非使用一些奇怪的 goto 语句。 - Shawn
你可以使用 List::Util 中的 reduce 函数,它执行左折叠。或者用循环实现。如果想要尝试 TCO 和深递归,可以使用其他语言(我比较偏爱 Scheme)。这不是 Perl 擅长的领域。但是,这里有一个例子,展示了如何在 Perl 中使用基于 goto 的尾调用。 - Shawn
我知道List::Util中的reducesum以及循环。我的问题是如何在Perl中实现TCO(尾递归优化),如果可能的话。 - Miroslav Popov
4个回答

4

你说得对,Perl不执行尾递归优化。

如果你有尾递归,你可以自己进行优化。但是话说回来,你没有尾递归。递归调用之后紧跟着一个加法。

因此让我们从将子程序改为只有尾递归开始。这可以通过向前传递执行最后一次操作所需的信息来完成。

sub _sum {
   my ($acc, $first, @rest) = @_;
   $acc += $first;
   return @rest ? _sum( $acc, @rest ) : $acc;
}

sub sum {
   my (@rest) = @_;
   return undef if !@rest;
   return _sum( 0, @rest );
}

现在我们可以进行尾调用优化。

  1. 将递归子例程的主体放置在一个无限循环中。
  2. do { @_ = ...; next; }替换recurse(...)

首先是在辅助函数中。

sub _sum {
   while (1) {
      my ($acc, $first, @rest) = @_;
      $acc += $first;
      if (@rest) {
         @_ = ( $acc, @rest );
      } else {
         return $acc;
      }
   }
}

sub sum {
   my (@rest) = @_;
   return undef if !@rest;
   return _sum( 0, @rest );
}

然后在主子程序中。
sub sum {
   my (@rest) = @_;
   return undef if !@rest;

   @_ = ( 0, @rest );
   while (1) {
      my ($acc, $first, @rest) = @_;
      $acc += $first;
      if (@rest) {
         @_ = ( $acc, @rest );
      } else {
         return $acc;
      }
   }
}

完成了。

...有点。现在我们可以执行很多其他的清理和优化。

让我们从改善流程开始。

sub sum {
   my (@rest) = @_;
   return undef if !@rest;

   @_ = ( 0, @rest );
   while (1) {
      my ($acc, $first, @rest) = @_;
      $acc += $first;
      return $acc if !@rest;

      @_ = ( $acc, @rest );
   }
}

不需要在循环中每次创建一个新的 $acc

sub sum {
   my (@rest) = @_;
   return undef if !@rest;

   my $acc = 0;
   while (1) {
      my ($first, @rest) = @_;
      $acc += $first;
      return $acc if !@rest;

      @_ = @rest;
   }
}

不再需要使用@_了。

sub sum {
   my (@rest) = @_;
   return undef if !@rest;

   my $acc = 0;
   while (1) {
      (my $first, @rest) = @rest;
      $acc += $first;
      return $acc if !@rest;
   }
}

让我们使用更经济的列表赋值替换它。
sub sum {
   my (@rest) = @_;
   return undef if !@rest;

   my $acc = 0;
   while (1) {
      my $first = shift(@rest);
      $acc += $first;
      return $acc if !@rest;
   }
}

让我们简化循环。

sub sum {
   my (@rest) = @_;
   return undef if !@rest;

   my $acc = 0;
   while (@rest) {
      my $first = shift(@rest);
      $acc += $first;
   }

   return $acc;
}

我们可以用更便宜的foreach循环来替换while循环。

sub sum {
   my (@rest) = @_;
   return undef if !@rest;

   my $acc = 0;
   for my $first (@rest) {
      $acc += $first;
   }

   return $acc;
}

$first@rest不再是合适的变量名。在此过程中,我们将消除一个无用的 @_ 的副本。

sub sum {
   return undef if !@_;

   my $acc = 0;
   $acc += $_ for @_;
   return $acc;
}

如果我们将$acc初始化为undef,则不再需要进行初始检查。
sub sum {
   my $acc;
   $acc += $_ for @_;
   return $acc;
}

达达!


2
这是一个使用我在评论中提到的goto功能的TCO版本:

使用这个版本可以使代码更加高效。

#!/usr/bin/env perl
use warnings;
use strict;
use feature qw/say/;

sub sum {
    return undef if @_ == 0;
    return $_[0] if @_ == 1;
    splice @_, 0, 2, $_[0] + $_[1];
    goto ∑
}

say sum(1..100);
say sum(1..100_000);

根据文档

goto &NAME形式与其他goto形式非常不同。实际上,它根本不是正常意义上的goto,并且没有与其他goto相关的污名。相反,它退出当前子例程(失去由local设置的任何更改),并立即使用@_的当前值调用命名的子例程。

我不建议实际使用它,因为与任何其他方法相比,它真的非常慢,但是它是可以做到的。

"最初的回答"


请注意,在该示例中不需要使用goto &sum。只需使用&sum即可。您将创建堆栈帧,但不会创建新的@_,因此在不牺牲太多速度的情况下节省了大量内存。实际上,如果您要使用goto,为什么不使用goto START;(在第一条语句之前放置START:)?它会做完全相同的事情,但是成本会便宜得多!您建议的方法很糟糕。 - ikegami
简而言之,goto &sub 在愚弄 caller 方面很有用,但在 TCO 方面无用。使用 goto STARTwhile (1) - ikegami
1
@ikegami goto START虽然没有调用,但并不是TCO。如果你只是用return ∑而不是goto &sum,你必须添加一个no warnings 'recursion';。所以我会推荐使用goto形式——除了我看到它花费了更多的时间,这一点我之前没有预料到。 - ysth
1
什么?优化调用仍然是在优化它。 - ikegami
我会使用此链接(Perl 5 - Try It Online)无论是否使用尾递归优化。 - mpapec

2
这里有一种使用通用的run-recur接口的技巧。这实际上是一个跳板。最初的回答中已经包含了html标签,无需更改。
sub recur (*@values) {
  :{ 'recur' => &recur, 'values' => @values }
}

sub run (&f) {
  my $r = &f();
  while $r.isa(Hash) && $r{'recur'} === &recur {
    $r = &f(|$r{'values'});
  }
  return $r;
}

为了使用它,我们将子程序传递给run,并传递循环参数及其初始值 -


sub sum ($n = 0) {
  run (sub ($m = $n, $r = 0) {
    if $m == 0 {
      return $r;
    }
    else {
      recur($m - 1, $r + $m);
    }
  })
}

我们使用更新后的参数来调用recur,而不是直接调用sum。以下是输出结果 -

NB:在这里,“recur”是指递归函数。

say sum(100_000);
# 100_000 + 99_999 + 99_997 + ... + 3 + 2 + 1 =
# => 5000050000

# cpu time: 10.61 sec

这里是对一个范围进行操作。我们使用循环变量来跟踪范围的索引,$i和返回值$r- 最初的回答。
sub sum (@range) {
  run (sub ($i = 0, $r = 0) {
    if $i >= @range {
      return $r;
    }
    else {
      recur($i + 1, $r + @range[$i]);
    }
  })
}

say sum(5..10);
# 5 + 6 + 7 + 8 + 9 + 10 =
# => 45

say sum(0..0);
# => 0

say sum(1..100_000);
# => 5000050000

# cpu time: 14.37 sec

这里介绍的其他技术要求您大幅修改程序以避免堆栈溢出。独特的run-recur接口使您可以递归地思考问题,并使其在恒定空间中运行。
以下是与Perl 5兼容的修订版本。令人惊讶的是,这个程序快了近50倍。也许减速归因于新语法糖的差劣实现?任何人都无法确定...
use strict;
use warnings;

sub recur {
  { recur => \&recur, values => \@_ }
}

sub run {
  my ($f, @init) = @_;
  my $r = &{$f}(@init);
  while (ref $r eq ref {} && $r->{'recur'} == \&recur) {
    $r = &{$f}(@{$r->{'values'}});
  }
  return $r;
}

sub sum {
  my ($n) = @_;
  run (sub {
    my ($m, $r) = @_;
    if ($m == 0) {
      return $r;
    }
    else {
      recur($m - 1, $r + $m);
    }
  }, $n, 0);
}

print sum(100_000);
# => 5000050000

# cpu: 0.25 sec
# mem: 3 Mb

And the sum variant that takes a range input -

sub sum {
  my (@range) = @_;
  run (sub {
    my ($i, $r) = @_;
    if ($i >= @range) {
      return $r;
    }
    else {
      recur($i + 1, $r + $range[$i]);
    }
  }, 0, 0);
}

print sum(1..100_000);
# => 5000050000

# cpu: 0.27 sec
# mem: 12 Mb

1
不错,但问题是关于 Perl 5 而不是 Perl 6 吗? - Håkon Hægland
1
@HåkonHægland,老实说,我对Perl一无所知。我只学了足够多的来回答这个问题。看到其他的答案建议OP改变原始程序的“形状”以使其堆栈安全,我感到很难过。 - Mulan
@HåkonHægland 我找到了一款Perl 5解释器,然后尽力写出了一个可用的Perl 5实现。它大部分与Perl 6相同,但缺少子程序参数语法糖。请问您是否有任何改进的建议? - Mulan
我无法在Perl 6中获取内存使用统计信息。有什么想法吗?我使用了这个在线编译器来获取Perl 5程序的内存使用情况。链接 - Mulan

0

一个文件通过减少参数的数量来调用自身,受到这篇文章的启发:一个简单的Perl递归示例

当然,这远非实际解决方案。

#!/usr/bin/perl
use strict;
use warnings;
use feature qw(say);

# Init the args at the first call
if (!@ARGV) {exec join(' ', $^X, $0, 1 .. 100_000)}

# Show progress
if (@ARGV % 100 == 0) {say scalar @ARGV}

my ($sum, $first, @rest) = @ARGV;
$sum += $first;

@rest
    ? exec join(' ', $^X, $0, $sum, @rest)
    : say $sum;

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接