Есть ли проблемы, которые нельзя написать с помощью хвостовой рекурсии?

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

Есть ли проблемы, которые просто невозможно написать в стиле хвостовой рекурсии, или всегда можно преобразовать наивно-рекурсивную функцию в хвостовую рекурсивную функцию?

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


person ctford    schedule 11.12.2009    source источник
comment
Я не согласен с тем, что хвостовая рекурсия - это оптимизация производительности. Это связано с правильностью: программирование на языке без этого не позволяет вам обрабатывать неограниченное повторение с помощью рекурсии, тогда как в языках с этой функцией вы можете использовать рекурсию (с некоторыми ограничениями) для неограниченного повторения. Это важный момент: (гарантированное) наличие хвостовой рекурсии изменяет семантику языка.   -  person harms    schedule 11.12.2009
comment
Как это меняет семантику? В обоих случаях описывается одно и то же абстрактное вычисление. Сложность результирующего кода во времени и пространстве, возможно, является деталью реализации, хотя и важной, если вы планируете фактически запустить программу.   -  person C. A. McCann    schedule 11.12.2009
comment
Возможно, мне следовало указать, что он меняет семантику, если у вас есть static стек, что, как я понимаю, является нормальным случаем, например с Java вы можете передать параметр при запуске для jvm о том, насколько большим должен быть стек, но стек не может расти и расти до тех пор, пока память машины не будет израсходована. Вы говорите об абстрактных вычислениях и в некотором смысле правы, но это классический пример дырявой абстракции: базовая реализация проявляет себя, и без гарантии хвостовых вызовов программист не может на них полагаться.   -  person harms    schedule 11.12.2009


Ответы (5)


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

Например, вот функция, содержащая два рекурсивных вызова:

(define (count-tree t)
  (if (pair? t)
    (+ (count-tree (car t)) (count-tree (cdr t)))
    1))

А вот как бы это выглядело, если бы вы преобразовали эту функцию в стиль передачи продолжения:

(define (count-tree-cps t ctn)
  (if (pair? t)
    (count-tree-cps (car t)
                    (lambda (L) (count-tree-cps (cdr t)
                                                (lambda (R) (ctn (+ L R))))))
    (ctn 1)))

Дополнительный аргумент ctn - это процедура, которая count-tree-cps вызывает вместо возврата. (ответ sdcvvc говорит, что вы не можете делать все в пространстве O (1), и это правильно; здесь каждое продолжение - это закрытие, которое занимает некоторую память.)

Я не преобразовывал звонки на car, cdr или + в хвостовые звонки. Это тоже можно сделать, но я предполагаю, что эти листовые вызовы действительно будут встроенными.

Теперь самое интересное. Chicken Scheme фактически выполняет это преобразование во всем компилируемом коде. Процедуры, составленные Chicken, никогда не возвращаются. Есть классическая статья, объясняющая, почему Chicken Scheme делает это, написанная в 1994 году до внедрения Chicken: МИНУСЫ не должны Минусы его аргументов, Часть II: Чейни о MTA

Как ни странно, стиль передачи продолжения довольно распространен в JavaScript. Вы можете использовать его для длительных вычислений, избегая всплывающего окна браузера с «медленным сценарием». И это привлекательно для асинхронных API. jQuery.get (простая оболочка вокруг XMLHttpRequest) явно в стиле продолжения; последний аргумент - это функция.

person Jason Orendorff    schedule 11.12.2009
comment
Я не знаю, был ли это лучший ответ на вопрос, но, безусловно, это был наиболее информативный ответ. +1 - person Imagist; 12.12.2009
comment
Легче представить преобразование языка с goto и без вызовов процедур в язык с хвостовыми рекурсивными вызовами: то есть каждый goto, кроме первого, становится вызовом процедуры с последующим завершением. Это преобразование, которое стоит за GOTO Дейкстры, которое считается вредным. - person Charles Stewart; 14.12.2009
comment
Разве count-tree-cps не должен содержать вложенные вызовы самого себя, а не count-tree? - person Nate Kohl; 04.11.2011
comment
@JasonOrendorff, не могли бы вы взглянуть на мой вопрос? - person refik; 11.01.2017

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

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

Вот пример функции, которая является «рекурсивной» (на самом деле это просто итерация), но не хвостовой рекурсией:

factorial n = if n == 0 then 1 else n * factorial (n-1)

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

factorial n = f n 1
   where f n product = if n == 0 then product else f (n-1) (n * product)

Внутренняя функция f является хвостовой рекурсивной и компилируется в жесткий цикл.


Я считаю полезными следующие различия:

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

  • В рекурсивной программе вы решаете проблему размера n, сначала решая две подзадачи размера n/2. Или, в более общем смысле, вы решаете проблему размера n, сначала решая подзадачу размера k и подзадачу размера n-k, где 1 < k < n. Быстрая сортировка и сортировка слиянием - два примера такого рода проблем, которые можно легко запрограммировать рекурсивно, но не так просто программировать итеративно или с использованием только хвостовой рекурсии. (По сути, вам нужно смоделировать рекурсию, используя явный стек.)

  • В динамическом программировании вы решаете задачу размера n, сначала решая все подзадачи всех размеров k, где k<n. Примером такого рода проблем является поиск кратчайшего маршрута от одной точки до другой в лондонском метро. (Лондонский метрополитен - это многосвязный график, и вы решаете проблему, сначала находя все точки, для которых кратчайший путь составляет 1 остановку, затем для которых кратчайший путь составляет 2 остановки и т. Д.)

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

person Norman Ramsey    schedule 12.12.2009
comment
некоторые среды выполнения имеют очень ограниченное пространство стека и неограниченную память кучи, поэтому да, полезно знать, как преобразовать любой код в хвостовой рекурсивный код. - person Will Ness; 07.08.2012
comment
Не могли бы вы взглянуть на мой вопрос? - person refik; 11.01.2017

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

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

Обратите внимание, что наличие хвостовой рекурсии не означает, что использование памяти постоянно. Это просто означает, что стек вызовов-возвратов не увеличивается.

person Community    schedule 11.12.2009
comment
-1: Не все рекурсивные методы можно сделать хвосторекурсивными. См. Мой ответ для примера. - person Ben S; 11.12.2009
comment
Вы можете написать алгоритм итеративного обхода дерева, используя стек. - person Kristopher Johnson; 11.12.2009
comment
Я имел в виду потреблять постоянный стек. Спасибо, что указали на мою ошибку. - person ctford; 11.12.2009
comment
@Ben S: в CPS [...] каждый вызов - это хвостовой вызов. Убедитесь сами в контексте: en.wikipedia.org/wiki/Continuation-passing_style - person Pascal Cuoq; 11.12.2009
comment
Рекурсивный алгоритм может использовать хвостовые вызовы тогда и только тогда, когда значения стека никогда не записываются и не читаются снова после рекурсивного вызова, то есть если предыдущие значения могут быть отброшены. В противном случае хвостовые вызовы использовать нельзя. - person Pop Catalin; 11.12.2009
comment
@Ben S: извините, но вы ответили неверно. Хвостовой рекурсивный обход и вставка дерева легко написать, используя передачу продолжения. См. Следующие URL-адреса: stackoverflow.com/questions/833180/handy- f-snippets / и fortysix-and-two.blogspot.com/2009/06/ - person Juliet; 12.12.2009

Вы не можете делать все в пространстве O (1) (теорема пространственной иерархии). Если вы настаиваете на использовании хвостовой рекурсии, вы можете сохранить стек вызовов как один из аргументов. Очевидно, это ничего не меняет; где-то внутри есть стек вызовов, вы просто делаете его явно видимым.

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

Такое преобразование не уменьшит сложность пространства.

Как прокомментировал Паскаль Куок, еще один способ - использовать CPS; тогда все вызовы хвостовые рекурсивные.

person sdcvvc    schedule 11.12.2009

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

person recursive    schedule 11.12.2009