image


Ниже речь пойдёт о старушке рекурсии, которую неплохо бы представлять, понимать и применять.


Примечание: Данная небольшая статья написана для беглого ознакомления с рекурсией, некоторыми примерами её применения и опасностями.


Определение


Для начала стоит сказать, что рекурсия относится не только к программированию. Рекурсия — это общее понятие, которое может быть присуще чему угодно и встречаться в повседневной жизни, но больше всего она распространена в информатике и математике. Для программистов же умение применять рекурсию — большой плюс в коллекцию полезных навыков.


Самая большая глупость — это делать то же самое и надеяться на другой результат.

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


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


Некоторые примеры


Рекурсию надо бы понять, а определение для этого подходит хуже, чем наглядные примеры. Для лучшего понимания, конечно, всё же следует прочитать определение, посмотреть на пример, снова прочитать определение и снова посмотреть на пример… Повторять, пока не придёт осознание.


Отличный пример вы можете найти тут.


Самое известное программисту применение рекурсии — задачи на вычисление чисел Фибоначчи или факториала. Давайте покажем, как это реализовать на языке C:


int fib(int n) { 
    if (n == 0) return 0; 
    if (n == 1 || n == 2) return 1; 
    return fib(n - 1) + fib(n - 2); 
} 

Можно показать, как это выглядело бы на Prolog
fib(1,1):-!.
fib(2,1):-!.
fib(N,R):- 
        N1 is N-1, fib(N1, R1),   
        N2 is N-2, fib(N2, R2),   
        R is R1 + R2.

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


Вычисление факториала. Пример хвостовой рекурсии
int factorial_rec(int n, int res) {
    if (n == 0) return res;
    return factorial_rec(n - 1, res * n);
}
int factorial(int n) {
    return factorial_rec(n, 1);
}

Ещё один опасный и интересный пример

Fork-бомба
Примечание: Рекурсивное создание процессов крайне быстро (из-за экспоненциального роста их количества) заполняет таблицу процессов, что достаточно опасно для системы.


 #include <unistd.h>
 int main() {
   while(true) fork();
 }

Reboot кнопкой после такого делать немного не приятно.


Для математика первой ассоциацией, скорее всего, будет фрактал. Фракталы прекрасны и приятно для глаза показывают свойства самоподобия.


Самые известные фракталы:


Кривая (снежинка) Коха

image


Треугольник Серпинского

image


Дерево Пифагора

image


Ну и в повседневной жизни классическим примером являются два зеркала, поставленных друг напротив друга.


Углубимся глубже


image


Проста ли рекурсия? Однозначно нет. На вид кажется, что всё просто, однако рекурсия таит в себе опасности (А иногда она просто не понятна).


Вернёмся к примеру с вычислением чисел Фибоначчи. Сразу заметим, что возвращаемым результатом функции является вызов этой же функции, а если быть точнее, то сумма результатов вызова двух функций (именно поэтому рекурсия не хвостовая). Становится понятно, что второй вызов не произойдёт, пока не завершится первый (в котором также будет вызов двух функций). Тут же пытливый ум заметит, что из рекурсивной функции должен существовать "нормальный" выход, без самовызова, иначе мы познакомимся с переполнением стека вызовов — это один из ключевых моментов, который стоит держать в голове при работе с функциями вызывающими сами себя.


Заметим, что дерево вызовов получится большим, но максимальное количество вызовов в стеке будет заметно меньше (N-1 при N > 2, соответственно).


Пример дерева вызовов для числа 6

image


Рекурсивные алгоритмы довольно-таки часто встречаются при работе с деревьями, сортировками и задачами на графах. Так что, чтобы лучше вникнуть нужна практика и для этого не плохо подходит вышеупомянутое (в частности, бинарные или общие деревья. Их реализация не так сложна, а опыт работы с рекурсией получится не плохой).


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


Для полноты картины обязательно надо упомянуть о борьбе с рекурсией.


Зачем же с ней бороться?

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


Под силу ли побороть любую рекурсию?

Однозначно да. Любой рекурсивный алгоритм можно переписать без использования рекурсии, а хвостовую рекурсию же очень легко перевести на итерацию (чем и занимаются некоторые компиляторы для оптимизации). Это также относится и к итерационным алгоритмам.


Самый известный способ — это использование стека. Здесь подробнее, для интересующихся.


Заключение


Спасибо за прочтение статьи. Надеюсь, что большинство не знакомых с рекурсией получили базовое представление о ней, а от знающих людей, конечно, хочется услышать дополнения и замечания в комментариях. Не бойтесь рекурсии и не переполняйте стек!


UPD: Добавлен корректный пример хвостовой рекурсии.

Поделиться с друзьями
-->

Комментарии (34)


  1. AnutaU
    17.01.2017 20:44
    +6

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

    У вас в примере не хвостовая рекурсия: после рекурсивных вызовов выполняется сложение.


    1. plaguedo
      17.01.2017 21:23
      +1

      Спасибо вам за замечание, исправил.


  1. SbWereWolf
    17.01.2017 23:46
    -1

    Под силу ли побороть любую рекурсию?

    не рекурсивный обход графа? не рекурсивный поиск пути? наверное можно, но как то сложно это :) с рекурсией проще


    1. lair
      18.01.2017 00:01
      +2

      не рекурсивный обход графа?

      А в чем проблема? И BFS, и DFS есть в нерекурсивных вариантах.


      не рекурсивный поиск пути?

      Алгоритмы Дийкстры, Беллмана-Форда, Флойд-Уоршелла и Джонсона — нерекурсивные.


      Когда графы большие, рекурсивные алгоритмы просто не справляются из-за глубины стека.


      1. Saffron
        18.01.2017 01:06

        По сшитому дереву можно даже обходить за константную память


        1. mayorovp
          18.01.2017 16:14

          И не обязательно по сшитому. Достаточно указателей на родителя.


      1. SbWereWolf
        18.01.2017 09:21

        спасибо за подсказку


  1. Saffron
    17.01.2017 23:46

    Вы привели много примеров рекурсии, и ни одного определения.


    1. KvanTTT
      18.01.2017 03:01

      Чтобы понять рекурсию нужно понять рекурсию...


      1. Saffron
        18.01.2017 03:13
        +3

        Не всякая фраза, удовлетворяющая грамматике, имеет смысл. Мы ведь не сможете представить треугольник с четырьмя углами и пятью рёбрами. Хотя по отдельности, треугольники, углы, рёбра существуют, да и числительные тоже бывают. Множество всех множеств — такой же парадокс, где за простыми словами скрыта невозможная концепция.

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


        1. loginsin
          18.01.2017 06:22
          +4

          Надо отметить, что весь пост можно сократить до вышеозначенной фразы. В нем не содержится ничего такого, что помогло бы в будущем принять решение о применении рекурсии. Чего стоит только ответ на вопрос «Зачем же с ней бороться?». Из ответа понятно чуть меньше, чем ничего.

          В свое время (очень давно) проходил собеседование, кажется в ABBYY, и там дали задачку по вычислению результата функции вроде f(xn)=z(f(xn-1)), где z — еще какая-то функция (не помню какая). Естественно, я подошел к решению с помощью рекурсии. И естественно, я был не первый такой умник, судя по ухмылке интервьюера. Довольно наглядно и на пальцах показал, что человечество еще не подошло к созданию таких вычислительных машин, чтобы вычислить с помощью рекурсии значение функции даже для f(5) в разумные пределы времени с разумным количеством памяти, несмотря на то, что результат вычисления находился в пределах от 1 до 100.

          К чему я это? Опытный взгляд увидит из картинки автора, что f(0) вычисляется 5 раз, f(1) уже 8 раз, f(2) тоже 5 раз. Да, для f(0), f(1), f(2) вычислений почти не производится, но для f(100) что будет? И что будет, если это не вычисление Фибоначчи, а что посложнее?

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


        1. KvanTTT
          18.01.2017 11:51

          Так это вообще скорее шутка)


  1. IIvana
    18.01.2017 07:01

    И что будет, если это не вычисление Фибоначчи, а что посложнее?
    Что будет, зависит от того, кто делает. Например, банальная мемоизация результатов чистых функций спасет отца русской демократии (да, я в курсе про переполнение инта):
    int a[100];
    
    int f(int i) {
        if (i<2) return i;
        else {
            if (a[i]==0) a[i]=f(i-1)+f(i-2);
            return a[i];
        }
    }
    int main() { std::cout << f(100); }
    

    В свое время, решая многочисленные задачки типа размена монет и подсчета вариантов, я быстро находил тривиальные лобовые экспоненциально-рекурсивные решения, которые тормозили на определенных объемах данных. И было 2 пути — либо каждый раз придумывать свой отдельный другой алгоритм именно для этой конкретной задачи (что любят называть термином динамическое программирование, хотя кое-кто (и я в том числе) причисляют к этому и рекурсию с мемоизацией), либо один раз и навсегда написать абстракцию универсального мемоизатора, и скармливать ему любые тривиально рекурсивные схемы. Даже кату по этому поводу создал — https://www.codewars.com/kata/550756a881b8bdba99000348 Хотя если мы дошли до ФП, и Хаскеля в частности, то это отдельная песня симфония.


    1. gorodnev
      18.01.2017 08:02

      Но ведь динамика элегантнее, разве нет? :)


      1. Saffron
        18.01.2017 11:18

        Запихните динамику внутрь гистоморфизма, он для того и создан.


    1. lair
      18.01.2017 09:42

      ("Динамическое программирование" — оно про другое.)


      Мемоизация — это способ борьбы с многократным вычислением одного и того же, а не с глубиной стека. А вот для борьбы с глубиной стека уже надо просто заменять алгоритм (либо на нерекурсивный, либо на алгоритм с хвостовой рекурсией).


    1. mayorovp
      18.01.2017 16:26
      -1

      Никакого "Принципиально другого алгоритма" придумывать не нужно. После того как вы сделали мемоизацию, достаточно просто перейти к рекурсивному алгоритму.


      1. Выбираем способ обхода (обычно оно очевидно)
      2. Пишем цикл, перебирающий параметры
      3. Пишем код, аналогичный вызову функции с этими параметрами, с учетом того что запомненного значения заведомо нет.
      4. Саму функцию меняем с учетом того что запомненное значение заведомо есть.

      Вот ваш случай:


      int a[100];
      
      int f(int i) {
          if (i<2) return i;
          else {
              return a[i];
          }
      }
      
      for (int i=2; i<100; i++)
          a[i] = f(i-1)+f(i-2);

      Это что, теперь называется "придумывать принципиально другой алгоритм"?


  1. bromzh
    18.01.2017 07:29
    +2

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


    Ну а для того чтобы нормально понять, что такое рекурсия в программировании, какие её типы бывают, какие проблемы возникают и как с ними правильно бороться, вполне хватит прочитать главу 1.2. из небезызвестной SICP. Лучшего объяснения я не видел.


    Можно было хотя бы разобрать алгоритмы и реализации программ, рисующих фракталы, чтобы было немного интересно. Но нет, тут только пара невзрачных картинок (даже Мандельброта и брокколи нет) без объяснений.


    PS А пример на прологе мне не кажется таким уж наглядным, хаскель подошёл бы лучше, так как для подобных примеров (числа фибоначчи, факториал, функция аккермана, и т.п.) код практически совпадает с мат. определением этих ф-ций (ага, я тоже умею копипастить):


    fac 0 = 1
    fac n = n * fac (n-1)
    
    fib 0 = 1
    fib 1 = 1
    fib n = fib (n-1) + fib (n-2)
    
    ack 0 n = n + 1
    ack m 0 = ack (m-1) 1
    ack m n = ack (m-1) (ack m (n-1))


  1. jknight
    18.01.2017 09:47
    +2

    Если уж статья рассчитана на людей, которые не в курсе, что же такое рекурсия, зачем же давать им столь ядовитые примеры? Сугубо из математического представления? Факториал — еще ладно, хрен с ним(хотя бы не создает извратной сложности алгоритма, но так писать тоже нельзя), но вот вычисление чисел Фибоначчи должно выглядеть исключительно как пример, показывающий, к чему приводит рекурсия, если ее применять бездумно(с расчетом количества операций и объяснением, к чему это может привести), не как пример применения рекурсии. А я вот во всем тексте увидел только невнятное «большое дерево вызовов», которое этот новичок скорее всего пролистает…


  1. apachik
    18.01.2017 14:01
    +1

    Один мой одноклассник (олимпиадник-математик во время занятия информатикой) как-то сказал:
    «Я долго не понимал что такое „динамика“, но когда мне объяснили, что динамика — это „рекурсия“, а рекурсия — это „индукция“, то все встало на свои места»


    1. roman_kashitsyn
      18.01.2017 14:25
      +2

      когда мне объяснили, что динамика — это „рекурсия“, а рекурсия — это „индукция“, то все встало на свои места

      Непонятно, как всё могло встать на места, если оба утверждения являются неверными.


      динамика — это „рекурсия“

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


      рекурсия — это „индукция“

      Рекурсия движется от сложной структуры к простой, пока не дойдёт до тривиальной базы. Индукция движется от тривиальной базы к всё более сложным структурам. Поэтому рекурсия — это индукция, вывернутая наизнанку.


      1. apachik
        18.01.2017 14:44

        спасибо, Кэп. а кавычки вы не заметили, да?


      1. mird
        20.01.2017 11:04
        -1

        То есть рекурсия это не индукция а дедукция.


    1. Deosis
      19.01.2017 07:47

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


  1. ParkeTg
    18.01.2017 15:20

    Если вы не поняли, что такое рекурсия — прочитайте это предложение еще раз.


    1. usrsse2
      18.01.2017 17:26
      +1

      это цикл, а не рекурсия


      1. syavadee
        19.01.2017 02:19

        тогда рекурсия — это цикл в функциональной парадигме


        1. mayorovp
          19.01.2017 08:42

          Так, да не так. Во-первых, циклу эквивалентна только хвостовая рекурсия. Во-вторых, побудительное предложение является императивным — а потому к ФП отношения не имеет.


          В-третьих, строго говоря, тут нет ни рекурсии, ни цикла — говорится же только прочитать предложение, а не делать что в нем написано :)


  1. subirdcom
    19.01.2017 18:52
    +1

    Самое известное программисту применение рекурсии — задачи на вычисление чисел Фибоначчи или факториала.
    Вычисление чисел Фибоначчи — самая известная программисту задача, которую не надо решать ни за экспоненциальное, ни за линейное время.


    1. nickolaym
      27.01.2017 15:27
      +1

      А вторая такая задача — это вычисление определителя матрицы.

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


  1. lito_atreides
    26.01.2017 00:18
    -1

    Когда диссер писал, то где-то вот здесь находил материал по теме http://mmse.xyz/


  1. nickolaym
    27.01.2017 15:21
    +1

    «Любую рекурсию можно переписать без использования рекурсии»?!
    Стек данных вместо стека возвратов — нет ли здесь размена шила на мыло и подмены понятий?


    1. mayorovp
      27.01.2017 15:37

      Суть в том, что у нас всего 1 мегабайт стека возвратов — и все адресное пространство для стека данных! :)


      1. nickolaym
        27.01.2017 16:06

        Размер стека возвратов регулируется, во-первых, и легко обходится, во-вторых.
        При достижении определённой глубины просто берём и вызываем функцию в контексте другого потока (можно даже другого процесса) (можно даже на другой машине в вычислительном кластере).

        Стек возвратов — универсальный инструмент, и в силу универсальности, избыточный.
        Если для вычисления рекурсивной функции у нас есть, грубо говоря, две-три-четыре точки возврата, то для кодирования их нам достаточно одного-полутора-двух бит, причём зачастую, эти значения можно даже не хранить, а вычислять из текущего контекста.
        Поэтому в конкретной функции стек данных решает. То есть, мы пишем свою маленькую виртуальную машинку под конкретную задачку.