Когда использовать рекурсию?


26

Когда есть некоторые (относительно) базовые (например, первокурсник, студент CS-уровня), когда можно использовать рекурсию, а не просто цикл?


2
Вы можете превратить любую рекурсию в цикл (со стеком).
Каве

Ответы:


19

Я преподавал C ++ студентам около двух лет и занимался рекурсией. Исходя из моего опыта, ваши вопросы и чувства очень распространены. В крайнем случае, некоторые студенты считают рекурсию трудной для понимания, в то время как другие хотят использовать ее практически для всего.

Я думаю, что Дейв хорошо резюмирует это: используйте это там, где это уместно. То есть используйте его, когда он кажется естественным. Когда вы сталкиваетесь с проблемой, когда она хорошо подходит, вы, скорее всего, узнаете ее: может показаться, что вы даже не можете найти итеративное решение. Кроме того, ясность является важным аспектом программирования. Другие люди (и вы тоже!) Должны иметь возможность читать и понимать код, который вы создаете. Я думаю, можно с уверенностью сказать, что итерационные циклы легче понять с первого взгляда, чем рекурсию.

Я не знаю, насколько хорошо вы знаете программирование или информатику в целом, но я твердо убежден, что не имеет смысла говорить о виртуальных функциях, наследовании или каких-либо дополнительных концепциях здесь. Я часто начинал с классического примера вычисления чисел Фибоначчи. Здесь он хорошо вписывается, поскольку числа Фибоначчи определяются рекурсивно . Это легко понять и не требует каких-либо причудливых особенностей языка. После того, как студенты получили базовое понимание рекурсии, мы еще раз взглянем на некоторые простые функции, которые мы создали ранее. Вот пример:

Содержит ли строка символ ?Икс

Вот как мы делали это раньше: перебираем строку и проверяем, содержит ли индекс .Икс

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
@ Рафаэль Согласен, точно. Некоторые вещи более естественно выражать итеративно, другие - рекурсивно. Это было то, что я пытался сделать :)
Juho

Хм, простите, если я ошибаюсь, но не лучше ли разделить строку возврата на условие if в примере кода, которое возвращает true, если x найден, иначе рекурсивная часть? Я не знаю, продолжает ли «или» выполняться, даже если он находит истину, но если это так, этот код крайне неэффективен.
MindlessRanger

@MindlessRanger Возможно, идеальный пример того, что рекурсивную версию сложнее понять и написать? :-)
Juho

Да, и мой предыдущий комментарий был неверным: «или» или «||» не проверяет следующие условия, если первое условие верно, поэтому нет неэффективности
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 ++ также оптимизируют хвостовые вызовы , поэтому нет никаких дополнительных затрат на использование рекурсии в C ++.


1
Есть ли у C ++ хвостовая рекурсия? Возможно, стоит отметить, что функциональные языки обычно это делают.
Луи

3
Спасибо Луи. Некоторые компиляторы C ++ оптимизируют хвостовые вызовы. (Хвостовая рекурсия - это свойство программы, а не языка.) Я обновил свой ответ.
Дейв Кларк,

По крайней мере, GCC оптимизирует удаленные вызовы (и даже некоторые формы неконцевых вызовов).
vonbrand

11

От того, кто практически живет в рекурсии, я постараюсь пролить свет на эту тему.

Когда вы впервые знакомитесь с рекурсией, вы узнаете, что это функция, которая вызывает себя и в основном демонстрируется с помощью таких алгоритмов, как обход дерева. Позже вы обнаружите, что он широко используется в функциональном программировании для таких языков, как LISP и F #. С F # я пишу, большинство из того, что я пишу, является рекурсивным и сопоставлением с образцом.

Если вы узнаете больше о функциональном программировании, таком как F #, вы узнаете, что списки F # реализованы в виде односвязных списков, что означает, что операции, которые обращаются только к заголовку списка, являются O (1), а доступ к элементу - O (n). Когда вы узнаете это, вы стремитесь просмотреть данные в виде списка, создавая новый список в обратном порядке, а затем переворачивая список, прежде чем вернуться из функции, которая очень эффективна.

Теперь, если вы начнете думать об этом, вы скоро поймете, что рекурсивные функции будут выдвигать кадр стека каждый раз, когда выполняется вызов функции, и могут вызвать переполнение стека. Однако, если вы создаете свою рекурсивную функцию, чтобы она могла выполнять хвостовой вызов, а компилятор поддерживает возможность оптимизировать код для хвостового вызова. т.е. .NET OpCodes.Tailcall Field вы не будете вызывать переполнение стека. С этого момента вы начинаете писать любой цикл как рекурсивную функцию, а любое решение - как совпадение; дни ifи whileтеперь история.

Как только вы переходите на ИИ, используя возврат на таких языках, как PROLOG, все становится рекурсивным. Хотя для этого нужно думать совершенно иначе, чем просто для императивного кода, если PROLOG является подходящим инструментом для решения проблемы, он освобождает вас от необходимости писать много строк кода и может значительно сократить количество ошибок. Смотрите: Amzi клиент eoTek

Вернемся к вашему вопросу о том, когда использовать рекурсию; Я смотрю на программирование одним способом: на одном конце - аппаратное обеспечение, а на другом - абстрактные концепции. Чем ближе к проблеме аппаратного обеспечения, тем больше я думаю о императивных языках ifи whileчем более абстрактно эта проблема, тем больше я думаю о языках высокого уровня с рекурсией. Однако, если вы начнете писать системный код низкого уровня и тому подобное и хотите убедиться, что он действителен, вам пригодятся такие решения, как средства доказательства теорем , которые в значительной степени зависят от рекурсии.

Если вы посмотрите на улицу Джейн, вы увидите, что они используют функциональный язык OCaml . Хотя я не видел ни одного из их кода, прочитав о том, что они упоминают о своем коде, они угрюмо думают рекурсивно.

РЕДАКТИРОВАТЬ

Поскольку вы ищете список применений, я дам вам базовую идею о том, что искать в коде, и список основных применений, которые в основном основаны на концепции катаморфизма, которая выходит за рамки базовых.

Для C ++: если вы определяете структуру или класс, который имеет указатель на ту же структуру или класс, тогда рекурсия должна быть рассмотрена для методов обхода, которые используют указатели.

Простой случай - это односторонний связанный список. Вы должны обработать список, начиная с головы или хвоста, а затем рекурсивно обходить список с помощью указателей.

Дерево - это еще один случай, когда часто используется рекурсия; настолько, что если вы видите обход дерева без рекурсии, вы должны начать спрашивать, почему? Это не так, но то, что следует отметить в комментариях.

Обычное использование рекурсии:


2
Это звучит как очень хороший ответ, хотя он и немного превосходит все, чему меня учат на уроках в ближайшее время, я верю.
Тейлор Хьюстон

1
@TaylorHuston Помните, что вы являетесь клиентом; спросите у учителя, какие концепции вы хотите понять. Он, вероятно, не будет отвечать на них в классе, но поймать его в рабочее время, и это может принести много дивидендов в будущем.
Гай Кодер

Хороший ответ, но он кажется слишком сложным для тех, кто не знает о функциональном программировании :).
pad

2
... ведущий наивного спрашивающего изучать функциональное программирование. Выиграть!
Джефф

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
Я не уверен, на какие «тайные» примеры вы ссылаетесь. Тем не менее, приятное обсуждение интеграции с ОО.
Дэйв Кларк

3

Первым примером, который использовался для обучения рекурсии в моем начальном классе программирования, была функция перечисления всех цифр числа отдельно в обратном порядке.

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

Или что-то в этом роде (я ухожу по памяти, а не тестирую). Кроме того, когда вы попадете в классы более высокого уровня, вы будете использовать рекурсию LOT, особенно в алгоритмах поиска, алгоритмах сортировки и т. Д.

Так что сейчас это может показаться бесполезной функцией в языке, но в долгосрочной перспективе это очень и очень полезно.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.