什么时候使用递归?


26

什么时候(相对)基本的(认为是大学一年级CS学生)实例使用递归而不是循环?


2
您可以将任何递归转换成一个循环(带有堆栈)。
卡韦

Answers:


18

我已经为本科生教过C ++大约两年了,涵盖了递归。根据我的经验,您的问题和想法普遍。在极端情况下,有些学生认为递归很难理解,而另一些学生则想将递归用于几乎所有内容。

我认为Dave总结得很好:在适当的地方使用它。即,在感觉自然时使用它。当您遇到一个非常合适的问题时,您很可能会意识到:似乎您甚至无法提出一个迭代解决方案。同样,清晰度是编程的重要方面。其他人(还有您!)也应该能够阅读和理解您生成的代码。我认为可以肯定地说,迭代循环比递归循环更容易理解。

我不知道您对编程或计算机科学的总体了解,但是我强烈感觉在这里谈论虚拟函数,继承或任何高级概念都没有道理。我经常从计算斐波那契数的经典例子开始。由于斐波那契数是递归定义的,因此非常适合这里。这很容易理解,不需要任何花哨的语言功能。在学生对递归有了一些基本了解之后,我们再来看一下我们先前构建的一些简单函数。这是一个例子:

字符串是否包含字符?x

这是我们之前的操作方式:迭代字符串,然后查看是否有任何索引包含。x

bool find(const std::string& s, char x)
{
   for(int i = 0; i < s.size(); ++i)
   {
      if(s[i] == x)
         return true;
   }

   return false;
}

接下来的问题是,可能我们这样做递归?当然可以,这是一种方法:

bool find(const std::string& s, int idx, char x)
{
   if(idx == s.size())
      return false;

   return s[idx] == x || find(s, ++idx);
}

那么,下一个自然的问题是,我们应该这样做吗?可能不会。为什么?很难理解,也很难提出。因此,它也更容易出错。


2
最后一段没有错;只是想经常提到,相同的推理在迭代解决方案中更倾向于递归(Quicksort!)。
拉斐尔

1
@Raphael完全同意。有些事情可以更自然地进行迭代表达,而另一些事情则可以递归表达。那就是我要提出的要点:)
Juho 2012年

嗯,如果我错了,请原谅我,但是如果您将示例代码中的返回行分隔为if条件,那会不会更好,如果找到x,则返回true,否则递归呢?我不知道'or'是否继续执行,即使它找到了正确的值,但如果是这样,则此代码效率很低。
MindlessRanger 2015年

@MindlessRanger递归版本更难理解和编写的一个完美示例?:-)
Juho 2015年

是的,我之前的评论是错误的,“或”或“ ||” 不检查下一个条件:第一条件为真,所以没有ineffiency
MindlessRanger

24

使用递归可以更自然地表达一些问题的解决方案。

例如,假设您有一个包含两种节点的树数据结构:叶子,它们存储一个整数值;和分支,它们在其字段中具有左右子树。假设叶子是有序的,因此最小值在最左边的叶子中。

假设任务是按顺序打印出树的值。这样做的递归算法很自然:

class Node { abstract void traverse(); }
class Leaf extends Node { 
  int val; 
  void traverse() { print(val); }
} 
class Branch extends Node {
  Node left, right;
  void traverse() { left.traverse(); right.traverse(); }
}

编写没有递归的等效代码会更加困难。试试吧!

更一般而言,对于诸如树之类的递归数据结构上的算法,或者对于自然可以分解为子问题的问题,递归效果很好。例如,检出除法算法

如果您真的想在最自然的环境中看到递归,那么您应该看一下像Haskell这样的函数式编程语言。在这种语言中,没有循环结构,因此所有内容都使用递归(或更高阶的函数,但又是另一回事,值得一提)来表达。

还要注意,功能性编程语言执行优化的尾递归。这意味着除非不需要,否则它们不会放下堆栈帧,本质上,可以将递归转换为循环。从实际的角度来看,您可以以自然的方式编写代码,但是可以获得迭代代码的性能。作为记录,C ++编译器似乎还优化了tail调用,因此在C ++中使用递归没有额外的开销。


1
C ++是否具有尾递归?值得指出的是,功能语言通常可以做到。
路易(Louis)

3
谢谢路易斯。一些C ++编译器优化了尾调用。(尾递归是程序的属性,而不是语言的属性。)我更新了答案。
Dave Clarke

至少GCC确实优化了尾部调用(甚至某些形式的非尾部调用)。
vonbrand

11

对于实际上生活在递归中的人,我将尝试阐明该主题。

初次介绍递归时,您会了解到它是一个调用自身的函数,并且基本上通过树遍历等算法进行了演示。后来您发现它在LISP和F#等语言的函数式编程中被大量使用。使用F#,我写的大部分内容都是递归和模式匹配。

如果您了解有关F#之类的函数编程的更多信息,您将学习F#列表是作为单链接列表实现的,这意味着仅访问列表开头的操作为O(1),而元素访问为O(n)。一旦了解了这一点,您就倾向于以列表的形式遍历数据,以相反的顺序构建新列表,然后在从函数返回之前反转列表,这非常有效。

现在,如果您开始考虑这一点,您很快就会意识到递归函数将在每次进行函数调用时推送堆栈帧,并且可能导致堆栈溢出。但是,如果构造递归函数以使其可以执行尾调用,并且编译器支持针对尾调用优化代码的功能。即.NET OpCodes.Tailcall字段,您不会导致堆栈溢出。至此,您开始将任何循环作为递归函数编写,并将任何决策作为匹配项编写。的日子ifwhile现在的历史。

一旦使用诸如PROLOG之类的语言使用回溯功能转移到AI,那么一切都是递归的。尽管这需要以与命令式代码完全不同的方式进行思考,但是如果PROLOG是解决问题的正确工具,那么它将使您免于编写大量代码行的负担,并可以显着减少错误数量。请参阅:Amzi客户eoTek

回到您何时使用递归的问题;我看编程的一种方式是一端使用硬件,另一端使用抽象概念。问题越接近硬件,我对使用if和使用命令式语言的思考while就越多,问题越抽象,我对递归的高级语言的思考也就越多。但是,如果您开始编写低级系统代码等,并且想要验证其有效性,那么您会发现诸如定理证明之类的解决方案非常有用,这在很大程度上依赖于递归。

如果您看一下简街,您会发现他们使用功能语言OCaml。尽管我还没有看到他们的任何代码,但从阅读他们提到的代码时,他们还是应该递归地思考。

编辑

由于您正在寻找用途清单,因此,我将为您提供在代码中寻找内容的基本概念,以及一个基本用途清单,这些清单主要基于超出了基本概念的Catamorphism概念。

对于C ++:如果定义的结构或类具有指向相同结构或类的指针,则应考虑使用指针的遍历方法的递归。

最简单的情况是单向链接列表。您可以从头或尾开始处理列表,然后使用指针递归遍历列表。

树是另一种经常使用递归的情况。如此之多,以至于如果您看到没有递归的遍历树,您应该开始问为什么?没错,但是注释中应注意一些内容。

递归的常见用法是:


2
听起来这是一个非常不错的答案,尽管我相信,这也比我在课堂上教的任何内容都要高。
Taylor Huston 2012年

1
@TaylorHuston记住您是客户;向老师询问您想了解的概念。他可能不会在上课时回答他们,但会在上班时间赶上他,将来可能会派发很多红利。
Guy Coder 2012年

好的答案,但是对于不了解函数编程的人来说似乎太高级了:)。
2012年

2
...引导天真的提问者学习函数式编程。赢得!
JeffE 2012年

8

为了给您一个比其他答案更简单的用例:递归与从公共源派生的树状(面向对象)类结构很好地混合在一起。一个C ++示例:

class Expression {
public:
    // The "= 0" means 'I don't implement this, I let my subclasses do that'
    virtual int ComputeValue() = 0;
}

class Plus : public Expression {
private:
    Expression* left
    Expression* right;
public:
    virtual int ComputeValue() { return left->ComputeValue() + right->ComputeValue(); }
}

class Times : public Expression {
private:
    Expression* left
    Expression* right;
public:
    virtual int ComputeValue() { return left->ComputeValue() * right->ComputeValue(); }
}

class Negate : public Expression {
private:
    Expression* expr;
public:
    virtual int ComputeValue() { return -(expr->ComputeValue()); }
}

class Constant : public Expression {
private:
    int value;
public:
    virtual int ComputeValue() { return value; }
}

上面的示例使用递归:ComputeValue是递归实现的。为了使示例工作,您使用了虚函数和继承。您不知道Plus类的左右部分到底是什么,但您不在乎:它可以计算自己的值,这是您需要知道的全部。

上述方法的关键优势在于,每个类都要照顾自己的计算。您将每个可能的子表达式的不同实现完全分开:它们对彼此的工作情况一无所知。这使对程序的推理更加容易,因此使程序更易于理解,维护和扩展。


1
我不确定您指的是什么“ arcane”示例。不过,对与OO集成的讨论不错。
戴夫·克拉克

3

在我的入门编程课程中用来教授递归的第一个示例是一个函数,该函数以相反的顺序分别列出数字中的所有数字。

void listDigits(int x){
     if (x <= 0)
        return;
     print x % 10;
     listDigits(x/10);
}

或类似的东西(我从这里开始而不是进行测试)。同样,当您进入更高级别的类时,将使用LOT递归,尤其是在搜索算法,排序算法等中。

因此,在当前语言中,它似乎似乎是一个无用的功能,但从长远来看,它非常有用。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.