it-swarm.dev

Является ли рекурсия быстрее, чем зацикливание?

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

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

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

261
Carson Myers

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

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

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

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

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

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

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

326
Dietrich Epp

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

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

Объяснение:

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

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

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

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

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

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

    а) установить и переместить ячейки памяти

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

    б) логика и арифметика

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

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

    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 "инструкция для изменения значения самого указателя инструкции. Мы назовем это конкретное использование инструкции магазина с новым именем: Jump. Мы используем новое имя, потому что его проще воспринимать как новую концепцию. Изменяя указатель инструкции, мы инструктируем агента "перейти к шагу x".

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

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

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

    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. Naming: присвоение имен определенной ячейке памяти, содержащей данные или удержание шага . Это просто "удобство", чтобы иметь. Мы не добавляем никаких новых инструкций, имея возможность определять "имена" для областей памяти. "Наименование" - это не инструкция для агента, а просто удобство для нас. Именование делает код (на данный момент) более легким для чтения и изменения.

       #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. Одноуровневая подпрограмма: Предположим, вам нужно выполнить ряд шагов часто. Вы можете сохранить шаги в именованной позиции в памяти, а затем перейти к этой позиции, когда вам нужно выполнить их (вызвать). В конце последовательности вам нужно вернуть в точку вызова продолжить исполнение. С помощью этого механизма вы создаете новые инструкции (подпрограммы), составляя основные инструкции.

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

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

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

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

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

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

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

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

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

...

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

Заключение:

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

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

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

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

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

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

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

47
Lucio M. Tato

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

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

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

33
starblue

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

12
mkorpela

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

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

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

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

12
Pasi Savolainen

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

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

8
Patrick Schlüter

Большинство ответов здесь неправильно. Правильный ответ это зависит. Например, вот две функции 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: ed памятью из кучи, доступ к которой намного медленнее.

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

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

6
Björn Lindqvist

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

2
Kilian Foth

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

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

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

1
noego

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

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

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

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

1
Amber

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

0
Roman A. Taycher

Согласно теории это то же самое. Рекурсия и цикл с одинаковой сложностью O() будут работать с той же теоретической скоростью, но, конечно, реальная скорость зависит от языка, компилятора и процессора. Пример со степенью числа может быть итеративно закодирован с помощью O (ln (n)):

  int power(int t, int k) {
  int res = 1;
  while (k) {
    if (k & 1) res *= t;
    t *= t;
    k >>= 1;
  }
  return res;
  }
0
Hydrophis Spiralis