Является ли рекурсия быстрее, чем зацикливание? - PullRequest
264 голосов
/ 16 апреля 2010

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

Что яя спрашиваю, является ли рекурсия когда-либо быстрее, чем цикл?Мне кажется, вы всегда сможете уточнить цикл и заставить его работать быстрее, чем рекурсивная функция, потому что цикл отсутствует, постоянно настраивая новые кадры стека.

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

Ответы [ 12 ]

327 голосов
/ 16 апреля 2010

Это зависит от используемого языка. Вы написали «независимый от языка», поэтому я приведу несколько примеров.

В Java, C и Python рекурсия довольно дорога по сравнению с итерацией (в целом), поскольку требует выделения нового фрейма стека. В некоторых C-компиляторах можно использовать флаг компилятора, чтобы устранить эти издержки, которые преобразуют определенные типы рекурсии (на самом деле, определенные типы хвостовых вызовов) в переходы вместо вызовов функций.

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

Я знаю, что в некоторых реализациях Scheme рекурсия, как правило, будет быстрее, чем зацикливание.

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

Добавление: В некоторых средах лучшая альтернатива - это не рекурсия и не итерация, а функции высшего порядка. К ним относятся «карта», «фильтр» и «уменьшить» (что также называется «сложить»). Они не только являются предпочтительным стилем, они не только часто более чистые, но и в некоторых средах эти функции являются первыми (или единственными), которые получают преимущество от автоматического распараллеливания - поэтому они могут быть значительно быстрее, чем итерация или рекурсия. Data Parallel Haskell является примером такой среды.

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

47 голосов
/ 19 января 2015

рекурсия когда-либо быстрее, чем цикл?

Нет, Итерация всегда будет быстрее, чем рекурсия. (в архитектуре фон Неймана)

Пояснение:

Если вы строите минимальное количество операций обычного компьютера с нуля, «Итерация» стоит на первом месте в качестве строительного блока и требует меньше ресурсов, чем «рекурсия», следовательно, это быстрее.

Создание псевдо-вычислительной машины с нуля:

Задайте себе вопрос : Что вам нужно для вычисления значения, т. Е. Чтобы следовать алгоритму и достичь результата?

Мы создадим иерархию понятий, начиная с нуля и определяя в первую очередь базовые, основные понятия, затем строим понятия второго уровня с ними и т. Д.

  1. Первая концепция: Ячейки памяти, память, состояние . Чтобы сделать что-то, вам нужно мест для хранения окончательных и промежуточных значений результатов. Давайте предположим, что у нас есть бесконечный массив «целочисленных» ячеек, называемых Memory , M [0..Infinite].

  2. Инструкции: сделать что-то - преобразовать ячейку, изменить ее значение. изменить состояние . Каждая интересная инструкция выполняет преобразование. Основные инструкции:

    а) Установка и перемещение ячеек памяти

    • сохранить значение в памяти, например: сохранить 5 м [4]
    • скопировать значение в другую позицию: например: сохранить m [4] m [8]

    b) Логика и арифметика

    • и, или, xor, не
    • add, sub, mul, div. например добавьте m [7] m [8]
  3. Исполнительный агент : ядро ​​ в современном процессоре. «Агент» - это то, что может выполнять инструкции. Агент также может быть человеком, следующим алгоритму на бумаге.

  4. Порядок шагов: последовательность инструкций : т. Е. Делать это сначала, делать это после и т. Д. Обязательная последовательность инструкций. Даже одна строка выражений является «обязательной последовательностью инструкций». Если у вас есть выражение с определенным «порядком оценки», то у вас есть шагов . Это означает, что даже одно составное выражение имеет неявные «шаги», а также имеет неявную локальную переменную (назовем это «результатом»). e.g.:

    4 + 3 * 2 - 5
    (- (+ (* 3 2) 4 ) 5)
    (sub (add (mul 3 2) 4 ) 5)  
    

    Выражение выше подразумевает 3 шага с неявной переменной «result».

    // pseudocode
    
           1. result = (mul 3 2)
           2. result = (add 4 result)
           3. result = (sub result 5)
    

    Таким образом, даже инфиксные выражения, поскольку у вас есть определенный порядок вычисления, являются обязательной последовательностью инструкций . Выражение подразумевает последовательность операций, которые должны быть выполнены в определенном порядке, и поскольку имеется шагов , существует также неявная промежуточная переменная «result».

  5. Указатель инструкций : Если у вас есть последовательность шагов, у вас также есть неявный «указатель инструкций». Указатель инструкции отмечает следующую инструкцию и продвигается после чтения инструкции, но до ее выполнения.

    В этой псевдо-вычислительной машине указатель инструкций является частью Памяти . (Примечание: обычно Указатель инструкций будет «специальным регистром» в ядре ЦП, но здесь мы упростим концепции и предположим, что все данные (включая регистры) являются частью «Памяти») *

  6. Прыжок - Если у вас есть заказанное количество шагов и Указатель инструкций , вы можете применить инструкцию " store ", чтобы изменить значение самого указателя инструкции. Мы назовем это конкретное использование инструкции store с новым именем: Jump . Мы используем новое имя, потому что его проще воспринимать как новую концепцию. Изменяя указатель инструкций, мы инструктируем агента «перейти к шагу x».

  7. Infiniт. е. итерация : с помощью прыжка назад теперь вы можете заставить агента «повторять» определенное количество шагов. На данный момент у нас есть бесконечная итерация.

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    
  8. Условное - Условное выполнение инструкций. С помощью «условного» предложения вы можете условно выполнить одну из нескольких инструкций в зависимости от текущего состояния (которое можно установить с помощью предыдущей инструкции).

  9. Правильная итерация : Теперь с условным предложением мы можем избежать бесконечного цикла инструкции jump back . Теперь у нас есть условный цикл и затем правильная итерация

    1. mov 1000 m[30]
    2. sub m[30] 1
    3. (if not-zero) jump 2  // jump only if the previous 
                            // sub instruction did not result in 0
    
    // this loop will be repeated 1000 times
    // here we have proper ***iteration***, a conditional loop.
    
  10. Именование : присвоение имен определенной ячейке памяти с данными или удержание шага . Это просто "удобство", чтобы иметь. Мы не добавляем никаких новых инструкций, имея возможность определять «имена» для областей памяти. «Наименование» - это не инструкция для агента, а просто удобство для нас. Именование делает код (на данный момент) более простым для чтения и изменения.

       #define counter m[30]   // name a memory location
       mov 1000 counter
    loop:                      // name a instruction pointer location
        sub counter 1
        (if not-zero) jmp-to loop  
    
  11. Одноуровневая подпрограмма : Предположим, вам нужно выполнить ряд шагов часто. Вы можете сохранить шаги в именованной позиции в памяти, а затем перейти к этой позиции, когда вам нужно выполнить их (вызвать). В конце последовательности вам нужно вернуть в точку , вызвав , чтобы продолжить выполнение. С помощью этого механизма вы создаете новые инструкции (подпрограммы), составляя основные инструкции.

    Реализация: (новые концепции не требуются)

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

    Проблема с реализацией one-level : Вы не можете вызвать другую подпрограмму из подпрограммы. Если вы это сделаете, вы перезапишете возвращающий адрес (глобальную переменную), поэтому вы не сможете вкладывать вызовы.

    Чтобы иметь лучшую реализацию для подпрограмм: вам нужен STACK

  12. Стек : Вы определяете пространство памяти для работы в качестве «стека», вы можете «выталкивать» значения в стек, а также «выталкивать» последнее «вытолкнутое» значение. Для реализации стека вам понадобится Указатель стека (аналогично указателю инструкций), который указывает на фактическую «верхушку» стека. Когда вы «нажимаете» значение, указатель стека уменьшается, и вы сохраняете значение. Когда вы щелкаете, вы получаете значение в фактическом указателе стека, а затем увеличивается указатель стека.

  13. Подпрограммы Теперь, когда у нас есть стек , мы можем реализовать надлежащие подпрограммы , разрешающие вложенные вызовы . Реализация аналогична, но вместо того, чтобы хранить указатель инструкций в предопределенной позиции памяти, мы «помещаем» значение IP в стек . В конце подпрограммы мы просто «выталкиваем» значение из стека, фактически возвращаясь к инструкции после первоначального вызова . Эта реализация, имеющая «стек», позволяет вызывать подпрограмму из другой подпрограммы. С помощью этой реализации мы можем создать несколько уровней абстракции при определении новых инструкций в качестве подпрограмм, используя основные инструкции или другие подпрограммы в качестве строительных блоков.

  14. Рекурсия : Что происходит, когда подпрограмма вызывает себя? Это называется "рекурсия".

    Проблема: Перезапись locВсе промежуточные результаты подпрограмма может хранить в памяти. Поскольку вы вызываете / повторно используете одни и те же шаги, , если , промежуточный результат сохраняется в предопределенных ячейках памяти (глобальные переменные), они будут перезаписаны при вложенных вызовах.

    Решение: Чтобы разрешить рекурсию, подпрограммы должны хранить локальные промежуточные результаты в стеке , следовательно, на каждом рекурсивном вызове ( прямые или косвенные) промежуточные результаты хранятся в разных местах памяти.

...

достигнув рекурсия мы останавливаемся здесь.

Вывод:

В архитектуре фон Неймана, очевидно, «Итерация» является более простой / основной концепцией, чем «Рекурсия» . Мы имеем форма "Итерация" на уровне 7, тогда как "Рекурсия" находится на уровне 14 иерархии понятий.

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

Какой из них "лучше"?

  • Вам следует использовать «итерацию», когда вы обрабатываете простые, последовательные структуры данных, и везде, где будет работать «простой цикл».

  • Вы должны использовать «рекурсию», когда вам нужно обработать рекурсивную структуру данных (мне нравится называть их «фрактальными структурами данных»), или когда рекурсивное решение явно более «изящно».

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

Наконец, обратите внимание, что у вас есть много возможностей использовать рекурсию. У вас есть рекурсивные структуры данных везде, сейчас вы смотрите на одну: части DOM, поддерживающие то, что вы читаете, - это RDS, выражение JSON - это RDS, иерархическая файловая система на вашем компьютере - это RDS, то есть: у вас есть корневой каталог, содержащий файлы и каталоги, каждый каталог, содержащий файлы и каталоги, каждый из этих каталогов, содержащий файлы и каталоги ...

33 голосов
/ 16 апреля 2010

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

У меня был случай, когда переписывание рекурсивного алгоритма в Java сделало его медленнее.

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

12 голосов
/ 16 апреля 2010

Рассмотрим, что абсолютно необходимо сделать для каждой итерации и рекурсии.

  • итерация: переход к началу цикла
  • рекурсия: переход к началу вызываемой функции

Вы видите, что здесь не так много места для разногласий.

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

12 голосов
/ 16 апреля 2010

Хвостовая рекурсия выполняется так же быстро, как и цикл. Во многих функциональных языках реализована хвостовая рекурсия.

8 голосов
/ 19 ноября 2014

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

TL; рекурсивные алгоритмы DR обычно хуже кешируют, чем итерационные.

6 голосов
/ 18 апреля 2016

Большинство ответов здесь неправильно . Правильный ответ - , это зависит . Например, вот две функции C, которые проходят по дереву. Сначала рекурсивный:

static
void mm_scan_black(mm_rc *m, ptr p) {
    SET_COL(p, COL_BLACK);
    P_FOR_EACH_CHILD(p, {
        INC_RC(p_child);
        if (GET_COL(p_child) != COL_BLACK) {
            mm_scan_black(m, p_child);
        }
    });
}

А вот та же функция, реализованная с использованием итерации:

static
void mm_scan_black(mm_rc *m, ptr p) {
    stack *st = m->black_stack;
    SET_COL(p, COL_BLACK);
    st_push(st, p);
    while (st->used != 0) {
        p = st_pop(st);
        P_FOR_EACH_CHILD(p, {
            INC_RC(p_child);
            if (GET_COL(p_child) != COL_BLACK) {
                SET_COL(p_child, COL_BLACK);
                st_push(st, p_child);
            }
        });
    }
}

Не важно понимать детали кода. Просто p являются узлами, а P_FOR_EACH_CHILD делает ходьбу. В итерационной версии нам нужен явный стек st, в который узлы помещаются, а затем выталкиваются и обрабатываются.

Рекурсивная функция выполняется намного быстрее, чем итеративная. Причина в том, что в последнем случае для каждого элемента требуется CALL для функции st_push, а затем еще один для st_pop.

В первом случае у вас есть только рекурсивный CALL для каждого узла.

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

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

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

2 голосов
/ 16 апреля 2010

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

1 голос
/ 01 декабря 2016

Функциональное программирование - это больше " что ", а не " как ".

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

Что важнее с точки зрения программиста, так это удобочитаемость и удобство обслуживания, а не оптимизация.Опять же, «преждевременная оптимизация - корень всего зла».

1 голос
/ 16 апреля 2010

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

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

Однако, обратите внимание, что я сказал, что «имеет жизнеспособные реализации в обеих формах». Для таких вещей, как многие алгоритмы сортировки, существует не очень жизнеспособный способ их реализации, который эффективно не устанавливает свою собственную версию стека, из-за порождения дочерних «задач», которые являются неотъемлемой частью процесса. Таким образом, рекурсия может быть такой же быстрой, как и попытка реализовать алгоритм с помощью цикла.

Изменить: Этот ответ предполагает, что нефункциональные языки, где большинство основных типов данных являются изменчивыми. Это не относится к функциональным языкам.

...