Рекурсия хвоста против классической рекурсии головы
прослушивание Scala курсов и объяснений, которые я часто слышу: ", но в реальном коде мы не используем рекурсию, а хвостовую рекурсию" .
Означает ли это, что в моем реальном коде я НЕ должен использовать рекурсию, но хвостовая рекурсия, которая очень похожа на цикл и не требует эпической фразы , чтобы понять рекурсию, вам сначала нужно понять рекурсию ".
В действительности, принимая во внимание ваш стек, вы, скорее всего, будете использовать петлеобразную хвостовую рекурсию.
Неужели я ошибаюсь? Разве эта "классическая" рекурсия хороша только для образовательных целей, чтобы заставить ваш мозг вернуться в прошлое университета?
Или, при всем этом, есть место, где мы можем его использовать.. где глубина вызовов рекурсии меньше X (где X - предел стека). Или мы можем начать кодирование с классической рекурсии, а затем, опасаясь, что ваш стек выдует в один прекрасный день, примените пару рефакторингов, чтобы сделать его хвостом, чтобы использовать еще сильнее в области рефакторинга?
Вопрос: Некоторые реальные образцы, которые вы использовали бы/использовали бы рекурсию "классической головы" в вашем реальном коде, который еще не реорганизован в хвост, возможно?
![just for fun, have found nice picture about that topic]()
Ответы
Ответ 1
Рекурсия хвоста == Петля
Вы можете взять любой цикл и выразить его как хвостовой рекурсивный вызов.
Фон: В чистом FP все должно привести к некоторому значению. Цикл while
в scala не приводит к какому-либо выражению, только побочные эффекты (например, обновление некоторой переменной). Он существует только для поддержки программистов, исходящих из императивного фона. scala рекомендует разработчикам пересмотреть замену цикла while
на рекурсию, которая всегда приводит к некоторому значению.
Итак, согласно Scala: Рекурсия - это новая итерация.
Однако есть проблема с предыдущим утверждением: в то время как "Обычный" рекурсивный код легче читать, он имеет ограничение производительности и несет неотъемлемый риск. С другой стороны, хвостовой рекурсивный код никогда не приведет к переполнению стека (по крайней мере, в Scala *), а производительность будет такой же, как и петли (на самом деле, я уверен, w60 > преобразует все хвостовые рекурсивные вызовы в простые старые итерации).
Возвращаясь к вопросу, ничего плохого в том, чтобы придерживаться "регулярной" рекурсии, если:
- Алгоритм, который вы используете при вычислении больших чисел (переполнение стека)
- Tail Recursion обеспечивает заметное увеличение производительности.
Ответ 2
Первое, на что нужно обратить внимание при разработке программного обеспечения, - это читаемость и ремонтопригодность кода. Глядя на характеристики производительности, в основном, преждевременная оптимизация.
Нет причин не использовать рекурсию, когда она помогает писать высококачественный код.
То же самое относится к хвостовой рекурсии против обычных циклов. Просто посмотрите на эту простую хвостовую рекурсивную функцию:
def gcd(a: Int, b: Int) = {
def loop(a: Int, b: Int): Int =
if (b == 0) a else loop(b, a%b)
loop(math.abs(a), math.abs(b))
}
Он вычисляет наибольший общий делитель двух чисел. Как только вы знаете алгоритм, ясно, как это работает - писать это с помощью цикла while не станет понятным. Вместо этого вы, вероятно, представите ошибку при первой попытке, потому что вы забыли сохранить новое значение в одну из переменных a
или b
.
С другой стороны, см. следующие две функции:
def goRec(i: Int): Unit = {
if (i < 5) {
println(i)
goRec(i+1)
}
}
def goLoop(i: Int): Unit = {
var j = i
while (j < 5) {
println(j)
j += 1
}
}
Какой из них легче читать? Они более или менее равны - весь синтаксический сахар, который вы получаете для хвостовых рекурсивных функций из-за природы, основанной на выражении Scalas, отсутствует.
Для рекурсивных функций есть еще одна вещь, которая приходит на ум: ленивая оценка. Если ваш код ленив, он может быть рекурсивным, но переполнение стека не произойдет. См. Эту простую функцию:
def map(f: Int => Int, xs: Stream[Int]): Stream[Int] = f -> xs match {
case (_, Stream.Empty) => Stream.Empty
case (f, x #:: xs) => f(x) #:: map(f, xs)
}
Будет ли он разбиваться на большие входы? Я так не думаю:
scala> map(_+1, Stream.from(0).takeWhile(_<=1000000)).last
res6: Int = 1000001
Попытка того же, что и Scalas List
, убьет вашу программу. Но поскольку Stream
ленив, это не проблема. В этом случае вы также можете написать хвостовую рекурсивную функцию, но обычно это не так легко.
Существует много алгоритмов, которые не будут ясны, когда они будут записаны итеративно - одним из примеров является глубину первого поиска графика. Вы хотите сохранить стек самостоятельно, чтобы сохранить значения, в которых вам нужно вернуться? Нет, вы не будете этого делать, потому что он подвержен ошибкам и выглядит уродливым (помимо любого определения рекурсии - он будет вызывать итеративную глубину первой поисковой рекурсии, а потому, что он должен использовать стек, а "нормальная" рекурсия должна использовать стек а также - он просто скрыт от разработчика и поддерживается компилятором).
Чтобы вернуться к точке преждевременной оптимизации, я услышал хорошую аналогию: когда у вас есть проблема, которая не может быть решена с помощью Int
, потому что ваши номера будут большими, и, вероятно, вы получите переполнение то не переключайтесь на Long
, потому что, вероятно, вы также получаете переполнение.
Для рекурсии это означает, что могут быть случаи, когда вы взорвите свой стек, но более вероятно, что при переключении на основанное только на памяти решение вы получите вместо этого ошибку с памятью. Лучшим советом является поиск другого алгоритма, который не выполняет это плохо.
Как вывод, попробуйте предпочесть рекурсию хвоста вместо циклов или нормальную рекурсию, потому что она наверняка не уничтожит ваш стек. Но когда вы можете сделать лучше, не стесняйтесь делать это лучше.
Ответ 3
Если вы не имеете дело с линейной последовательностью, то попытка написать функцию хвостового рекурсии для прохождения всей коллекции очень сложна. В таких случаях, ради удобочитаемости/ремонтопригодности, вы обычно используете обычную рекурсию.
Общим примером этого является обход структуры данных двоичного дерева. Для каждого node вам может потребоваться повторить как левый, так и правый дочерние узлы. Если вы попытаетесь написать такую функцию рекурсивно, где сначала будет посещен левый node, а затем правый, вам нужно будет сохранить какую-то вспомогательную структуру данных для отслеживания всех оставшихся правых узлов, которые нужно посетить, Однако вы можете добиться того же самого, просто используя стек, и это будет более читаемым.
Примером этого является метод iterator
из дерева Scala RedBlack
:
def iterator: Iterator[(A, B)] =
left.iterator ++ Iterator.single(Pair(key, value)) ++ right.iterator
Ответ 4
Существует два основных вида рекурсии:
- рекурсия головы
- Рекурсия хвоста
В рекурсии головы функция выполняет свой рекурсивный вызов, а затем выполняет еще несколько вычислений, например, используя результат рекурсивного вызова. В хвостовой рекурсивной функции все вычисления происходят сначала, а рекурсивный вызов - это последнее, что происходит.
Важность этого различия не выпрыгивает на вас, но его чрезвычайно важно! Представьте себе хвостовую рекурсивную функцию. Он работает. Он завершает все его вычисления. В качестве своего последнего действия он готов сделать свой рекурсивный вызов. Что, на данный момент, это использование фрейма стека? Вовсе нет. Нам больше не нужны наши локальные переменные, поскольку они выполнялись со всеми вычислениями. Нам не нужно знать, какая функция была, потому что они просто собирались снова войти в ту же самую функцию. Scala, в случае хвостовой рекурсии, может устранить создание нового фрейма стека и просто повторно использовать текущий фрейм стека. Стек никогда не становится глубже, независимо от того, сколько раз рекурсивный вызов выполняется. Это voodoo, который делает рекурсию хвоста специальным в Scala.
Посмотрим на пример.
def factorial1(n:Int):Int =
if (n == 0) 1 else n * factorial1(n -1)
def factorial2(n:Int):Int = {
def loop(acc:Int,n:Int):Int =
if (n == 0) 1 else loop(acc * n,n -1)
loop(1,n)
}
Кстати, некоторые языки достигают аналогичного конца путем преобразования хвостовой рекурсии в итерацию, а не манипулирования стеком.
Это не будет работать с рекурсией головы. Понимаете, почему? Представьте себе рекурсивную функцию головы. Сначала он выполняет некоторую работу, затем делает рекурсивный вызов, а затем выполняет немного больше работы. Мы не можем просто повторно использовать текущий стек, когда делаем этот рекурсивный вызов. Постарались, чтобы информация о кадре стека после завершения рекурсивного вызова. Он имеет локальные переменные, включая результат (если есть), возвращаемый рекурсивным вызовом.
Вот вопрос для вас. Является ли пример function factorial1 рекурсивным или хвостовым рекурсивным? Ну, что он делает? (A) Он проверяет, равен ли его параметр 0. (B) Если это так, он возвращает 1, поскольку факториал 0 равен 1. (C) Если нет, он возвращает n умножить на результат рекурсивного вызова. Рекурсивный вызов - это последнее, что мы набрали до окончания функции. Thats хвост рекурсии, не так ли? Wrong. Рекурсивный вызов выполняется, и THEN n умножается на результат, и этот продукт возвращается. На самом деле это рекурсия головы (или средняя рекурсия, если хотите), потому что рекурсивный вызов - это не последнее, что происходит.
Для получения дополнительной информации см. ссылку