Answers:
我已经为本科生教过C ++大约两年了,涵盖了递归。根据我的经验,您的问题和想法很普遍。在极端情况下,有些学生认为递归很难理解,而另一些学生则想将递归用于几乎所有内容。
我认为Dave总结得很好:在适当的地方使用它。即,在感觉自然时使用它。当您遇到一个非常合适的问题时,您很可能会意识到:似乎您甚至无法提出一个迭代解决方案。同样,清晰度是编程的重要方面。其他人(还有您!)也应该能够阅读和理解您生成的代码。我认为可以肯定地说,迭代循环比递归循环更容易理解。
我不知道您对编程或计算机科学的总体了解,但是我强烈感觉在这里谈论虚拟函数,继承或任何高级概念都没有道理。我经常从计算斐波那契数的经典例子开始。由于斐波那契数是递归定义的,因此非常适合这里。这很容易理解,不需要任何花哨的语言功能。在学生对递归有了一些基本了解之后,我们再来看一下我们先前构建的一些简单函数。这是一个例子:
字符串是否包含字符?
这是我们之前的操作方式:迭代字符串,然后查看是否有任何索引包含。
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);
}
那么,下一个自然的问题是,我们应该这样做吗?可能不会。为什么?很难理解,也很难提出。因此,它也更容易出错。
使用递归可以更自然地表达一些问题的解决方案。
例如,假设您有一个包含两种节点的树数据结构:叶子,它们存储一个整数值;和分支,它们在其字段中具有左右子树。假设叶子是有序的,因此最小值在最左边的叶子中。
假设任务是按顺序打印出树的值。这样做的递归算法很自然:
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 ++中使用递归没有额外的开销。
对于实际上生活在递归中的人,我将尝试阐明该主题。
初次介绍递归时,您会了解到它是一个调用自身的函数,并且基本上通过树遍历等算法进行了演示。后来您发现它在LISP和F#等语言的函数式编程中被大量使用。使用F#,我写的大部分内容都是递归和模式匹配。
如果您了解有关F#之类的函数编程的更多信息,您将学习F#列表是作为单链接列表实现的,这意味着仅访问列表开头的操作为O(1),而元素访问为O(n)。一旦了解了这一点,您就倾向于以列表的形式遍历数据,以相反的顺序构建新列表,然后在从函数返回之前反转列表,这非常有效。
现在,如果您开始考虑这一点,您很快就会意识到递归函数将在每次进行函数调用时推送堆栈帧,并且可能导致堆栈溢出。但是,如果构造递归函数以使其可以执行尾调用,并且编译器支持针对尾调用优化代码的功能。即.NET OpCodes.Tailcall字段,您不会导致堆栈溢出。至此,您开始将任何循环作为递归函数编写,并将任何决策作为匹配项编写。的日子if
和while
现在的历史。
一旦使用诸如PROLOG之类的语言使用回溯功能转移到AI,那么一切都是递归的。尽管这需要以与命令式代码完全不同的方式进行思考,但是如果PROLOG是解决问题的正确工具,那么它将使您免于编写大量代码行的负担,并可以显着减少错误数量。请参阅:Amzi客户eoTek
回到您何时使用递归的问题;我看编程的一种方式是一端使用硬件,另一端使用抽象概念。问题越接近硬件,我对使用if
和使用命令式语言的思考while
就越多,问题越抽象,我对递归的高级语言的思考也就越多。但是,如果您开始编写低级系统代码等,并且想要验证其有效性,那么您会发现诸如定理证明之类的解决方案非常有用,这在很大程度上依赖于递归。
如果您看一下简街,您会发现他们使用功能语言OCaml。尽管我还没有看到他们的任何代码,但从阅读他们提到的代码时,他们还是应该递归地思考。
编辑
由于您正在寻找用途清单,因此,我将为您提供在代码中寻找内容的基本概念,以及一个基本用途清单,这些清单主要基于超出了基本概念的Catamorphism概念。
对于C ++:如果定义的结构或类具有指向相同结构或类的指针,则应考虑使用指针的遍历方法的递归。
最简单的情况是单向链接列表。您可以从头或尾开始处理列表,然后使用指针递归遍历列表。
树是另一种经常使用递归的情况。如此之多,以至于如果您看到没有递归的遍历树,您应该开始问为什么?没错,但是注释中应注意一些内容。
递归的常见用法是:
为了给您一个比其他答案更简单的用例:递归与从公共源派生的树状(面向对象)类结构很好地混合在一起。一个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类的左右部分到底是什么,但您不在乎:它可以计算自己的值,这是您需要知道的全部。
上述方法的关键优势在于,每个类都要照顾自己的计算。您将每个可能的子表达式的不同实现完全分开:它们对彼此的工作情况一无所知。这使对程序的推理更加容易,因此使程序更易于理解,维护和扩展。
在我的入门编程课程中用来教授递归的第一个示例是一个函数,该函数以相反的顺序分别列出数字中的所有数字。
void listDigits(int x){
if (x <= 0)
return;
print x % 10;
listDigits(x/10);
}
或类似的东西(我从这里开始而不是进行测试)。同样,当您进入更高级别的类时,将使用LOT递归,尤其是在搜索算法,排序算法等中。
因此,在当前语言中,它似乎似乎是一个无用的功能,但从长远来看,它非常有用。