Коли використовувати рекурсію?


26

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


2
ви можете перетворити будь-яку рекурсію в цикл (зі стеком).
Kaveh

Відповіді:


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
@Raphael Погодився точно. Деякі речі більш природно виражати ітераційно, інші - рекурсивно. Це був сенс, який я намагався зробити :)
Juho

Гм, вибачте мені, якщо я помиляюся, але чи не було б краще, якщо ви розділили рядок повернення на умову if у коді прикладу, яка повертає істину, якщо знайдено х, інакше рекурсивну частину? Я не знаю, чи 'чи' продовжує виконуватись, навіть якщо він виявляється істинним, але якщо так, цей код є дуже неефективним.
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
Чи є у С ++ хвоста рекурсія? Можливо, варто зазначити, що функціональні мови зазвичай є.
Луї

3
Спасибі Луї. Деякі компілятори C ++ оптимізують хвостові дзвінки. (Рекурсія хвоста - це властивість програми, а не мова.) Я оновив свою відповідь.
Дейв Кларк

Принаймні, GCC оптимізує хвостові дзвінки (і навіть деякі форми безхвильових дзвінків).
vonbrand

11

Від того, хто практично живе в рекурсії, я спробую пролити трохи світла на цю тему.

Під час першого вступу до рекурсії ви дізнаєтесь, що це функція, яка викликає себе і в основному демонструється за допомогою таких алгоритмів, як обхід дерева. Пізніше ви виявите, що він використовується в функціональному програмуванні для таких мов, як LISP і F #. З F #, що я пишу, більшість того, що я пишу, є рекурсивним і узорним.

Якщо ви дізнаєтесь більше про функціональне програмування, наприклад F #, ви дізнаєтесь списки F # реалізуються як спільно пов'язані списки, це означає, що операції, які мають доступ лише до заголовка списку, є O (1), а доступ до елементів - O (n). Після того, як ви дізнаєтесь про це, ви схильні пересувати дані як список, будуючи новий список у зворотному порядку, а потім обертаючи список перед поверненням із функції, яка є дуже ефективною.

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

Як тільки ви перейдете до AI, використовуючи зворотний трек на таких мовах, як PROLOG, все рекурсивно. Хоча для цього потрібно мислити таким чином, що відрізняється від імперативного коду, якщо PROLOG - це правильний інструмент для вирішення проблеми, він звільняє вас від тягаря необхідності писати багато рядків коду і може значно зменшити кількість помилок. Дивіться: Amzi customer eoTek

Щоб повернутися до свого питання про те, коли використовувати рекурсію; Один із способів, на який я дивлюсь, програмування - це апаратне забезпечення на одному кінці та абстрактні поняття на іншому кінці. Чим ближче до обладнання проблема , тим більше я думаю , що в імперативних мовах з ifі while, тим більше абстрактні проблеми, тим більше я думаю , що в мовах високого рівня з рекурсією. Однак якщо ви почнете писати системний код низького рівня і подібний, і хочете переконатися, що він дійсний, ви знайдете такі рішення докази теореми , які дуже залежать від рекурсії.

Якщо ви подивитесь на Джейн Стріт , то побачите, що вони використовують функціональну мову OCaml . Поки я не бачив жодного їх коду, з читання того, що вони згадують про свій код, вони потворно думають рекурсивно.

EDIT

Оскільки ви шукаєте список застосувань, я дам вам основне уявлення про те, що шукати в коді та перелік основних способів використання, які в основному базуються на концепції катаморфізму, що виходить за рамки основ.

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

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

Дерево - ще один випадок, коли часто застосовується рекурсія; настільки, що якщо ви бачите обхід дерева без рекурсії, вам слід почати запитувати, чому? Це не помиляється, але щось, що слід зазначити в коментарях.

Поширеними способами рекурсії є:


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

1
@TaylorHuston Пам'ятайте, що ви - клієнт; запитайте у вчителя поняття, які ви хочете зрозуміти. Він, мабуть, не відповість на них у класі, але спіймає його в робочий час, і це може виплатити багато дивідендів у майбутньому.
Хлопець Кодер

Приємна відповідь, але здається занадто розвиненою для тих, хто не знає про функціональне програмування :).
колодка

2
... наведення наївного запитувача на вивчення функціонального програмування. Виграй!
JeffE

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);
}

Або щось подібне (я йду з пам’яті тут, а не тестую). Також, потрапляючи в класи вищого рівня, ви будете використовувати рекурсію ЛОТ, особливо в алгоритмах пошуку, алгоритмах сортування тощо.

Тож це може здатися марною функцією в мові зараз, але вона дуже корисна в перспективі.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.