Что такое хвостовая рекурсия?

1697

Начав изучать шепот, я натолкнулся на термин рекурсивный хвост . Что это значит точно?

Ben Lever
источник
155
Для любопытных: и в то время, и в то время как были на языке в течение очень долгого времени. Пока использовался в старом английском; в то время как среднеанглийское развитие while. Как союзы они взаимозаменяемы по значению, но пока не сохранились в стандартном американском английском.
Филипп Бартузи
14
Может быть, уже поздно, но это довольно хорошая статья о хвостовой
рекурсии
5
Одним из значительных преимуществ идентификации хвостовой рекурсивной функции является то, что она может быть преобразована в итеративную форму и, таким образом, освободить алгоритм от накладных расходов на метод. Возможно, вам понравятся отклики @Kyle Cronin и нескольких других, представленных ниже
KGhatak
Эта ссылка от @yesudeep - лучшее, самое подробное описание, которое я нашел - lua.org/pil/6.3.html
Джефф Фишер
1
Может ли кто-нибудь сказать мне: сортировка слиянием и быстрая сортировка используют хвостовую рекурсию (TRO)?
majurageerthan

Ответы:

1722

Рассмотрим простую функцию, которая добавляет первые N натуральных чисел. (например sum(5) = 1 + 2 + 3 + 4 + 5 = 15).

Вот простая реализация JavaScript, которая использует рекурсию:

function recsum(x) {
    if (x === 1) {
        return x;
    } else {
        return x + recsum(x - 1);
    }
}

Если бы вы позвонили recsum(5), это то, что интерпретатор JavaScript будет оценивать:

recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
15

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

Вот хвостовая рекурсивная версия той же функции:

function tailrecsum(x, running_total = 0) {
    if (x === 0) {
        return running_total;
    } else {
        return tailrecsum(x - 1, running_total + x);
    }
}

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

tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15

В случае хвостовой рекурсии, с каждой оценкой рекурсивного вызова, running_totalобновляется.

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

Лорин Хохштайн
источник
32
Могу ли я сказать, что с хвостовой рекурсией окончательный ответ рассчитывается по последнему вызову одного метода? Если это НЕ хвостовая рекурсия, вам нужны все результаты для всех методов, чтобы вычислить ответ.
chrisapotek
2
Вот приложение, которое представляет несколько примеров на Lua: lua.org/pil/6.3.html Может быть полезно пройти через это! :)
yesudeep
2
Может кто-нибудь ответить на вопрос chrisapotek? Я запутался, как tail recursionэтого достичь на языке, который не оптимизирует удаленные вызовы.
Кевин Мередит
3
@KevinMeredith «хвостовая рекурсия» означает, что последний оператор в функции - это рекурсивный вызов той же функции. Вы правы, что нет смысла делать это на языке, который не оптимизирует эту рекурсию. Тем не менее, этот ответ действительно показывает концепцию (почти) правильно. Это было бы более ясно, если бы "else:" было опущено. Не изменил бы поведение, но поместил бы хвостовой вызов как независимое утверждение. Я представлю это как редактирование.
ToolmakerSteve
2
Таким образом, в python нет никакого преимущества, потому что при каждом вызове функции tailrecsum создается новый кадр стека - верно?
Quazi Irfan
708

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

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

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

henrebotha
источник
17
«Я почти уверен, что Лисп делает это», - делает Схема, но Common Lisp не всегда.
Аарон
2
@Daniel "По сути, возвращаемое значение любого заданного рекурсивного шага совпадает с возвращаемым значением следующего рекурсивного вызова." - Я не вижу этого аргумента, сохраняющего значение true для фрагмента кода, опубликованного Лорин Хохштайн. Можете ли вы уточнить?
Компьютерщик
8
@Geek Это действительно запоздалый ответ, но это действительно так в примере Лорин Хохштайн. Расчет для каждого шага выполняется до рекурсивного вызова, а не после него. В результате каждая остановка просто возвращает значение непосредственно из предыдущего шага. Последний рекурсивный вызов завершает вычисление, а затем возвращает окончательный результат без изменений полностью вниз по стеку вызовов.
Рейраб
3
Scala делает, но вам нужен @tailrec, указанный для его применения.
SilentDirge
2
«Таким образом, вы не получите результат своих расчетов, пока не вернетесь после каждого рекурсивного вызова». - может быть, я неправильно понял это, но это не особенно верно для ленивых языков, где традиционная рекурсия - единственный способ получить результат без вызова всех рекурсий (например, сворачивание бесконечного списка Bools с & &).
hasufell
206

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

while(E) { S }; return Q

где E и Qявляются выражениями и Sпредставляет собой последовательность операторов, и превращают ее в хвостовую рекурсивную функцию

f() = if E then { S; return f() } else { return Q }

Конечно, E , Sи Qдолжны быть определены , чтобы вычислить некоторые интересные значения по некоторым переменным. Например, функция зацикливания

sum(n) {
  int i = 1, k = 0;
  while( i <= n ) {
    k += i;
    ++i;
  }
  return k;
}

эквивалентна хвостовой рекурсивной функции

sum_aux(n,i,k) {
  if( i <= n ) {
    return sum_aux(n,i+1,k+i);
  } else {
    return k;
  }
}

sum(n) {
  return sum_aux(n,1,0);
}

(Это «обертывание» хвостовой рекурсивной функции с функцией с меньшим количеством параметров является обычной функциональной идиомой.)

Крис Конвей
источник
В ответе @LorinHochstein я понял, основываясь на его объяснении, что хвостовая рекурсия должна быть, когда рекурсивная часть следует за «возвратом», однако у вас хвостовая рекурсия - нет. Вы уверены, что ваш пример правильно считается хвостовой рекурсией?
CodyBugstein
1
@Imray Хвосто-рекурсивная часть - это оператор return sum_aux внутри sum_aux.
Крис Конвей
1
@lmray: код Криса по сути эквивалентен. Порядок if / then и стиль ограничивающего теста ... if x == 0 против if (i <= n) ... - это не то, за что можно зацикливаться. Дело в том, что каждая итерация передает свой результат следующей.
Тейлор
else { return k; }можно изменить наreturn k;
c0der
144

Этот отрывок из книги « Программирование на Lua» показывает, как сделать правильную рекурсию хвоста (на Lua, но должна применяться и к Lisp) и почему это лучше.

Хвост вызова [хвост рекурсии] является своего рода Goto , одетый , как вызов. Хвостовой вызов происходит, когда функция вызывает другое как последнее действие, так что ей больше нечего делать. Например, в следующем коде вызов gявляется хвостовым вызовом:

function f (x)
  return g(x)
end

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

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

function foo (n)
  if n > 0 then return foo(n - 1) end
end

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

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

function room1 ()
  local move = io.read()
  if move == "south" then return room3()
  elseif move == "east" then return room2()
  else print("invalid move")
       return room1()   -- stay in the same room
  end
end

function room2 ()
  local move = io.read()
  if move == "south" then return room4()
  elseif move == "west" then return room1()
  else print("invalid move")
       return room2()
  end
end

function room3 ()
  local move = io.read()
  if move == "north" then return room1()
  elseif move == "east" then return room4()
  else print("invalid move")
       return room3()
  end
end

function room4 ()
  print("congratulations!")
end

Итак, вы видите, когда вы делаете рекурсивный вызов вроде:

function x(n)
  if n==0 then return 0
  n= n-2
  return x(n) + 1
end

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

Hoffmann
источник
9
Это отличный ответ, потому что он объясняет влияние хвостовых вызовов на размер стека.
Эндрю Свон
@AndrewSwan Действительно, хотя я полагаю, что первоначальному спрашивающему и случайному читателю, который может наткнуться на этот вопрос, может быть лучше подан с принятым ответом (поскольку он может не знать, что такое стек на самом деле.) Кстати, я использую Jira, большой поклонник.
Гофман
1
Мой любимый ответ также из-за включения значения размера стека.
njk2015
80

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

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

В основном рекурсии Tail могут быть оптимизированы в итерацию.

FlySwat
источник
1
«Большой рекурсивный запрос может вызвать переполнение стека». должен быть в первом абзаце, а не во втором (хвостовой рекурсии)? Большим преимуществом хвостовой рекурсии является то, что она может быть (например, схема) оптимизирована таким образом, чтобы не «накапливать» вызовы в стеке, поэтому в большинстве случаев она позволит избежать переполнения стека!
Оливье Дюлак
70

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

хвостовая рекурсия / n ./

Если вы уже не устали от этого, посмотрите хвостовую рекурсию.

похлопывание
источник
68

Вместо объяснения словами, вот пример. Это версия Scheme факториальной функции:

(define (factorial x)
  (if (= x 0) 1
      (* x (factorial (- x 1)))))

Вот версия факториала с хвостовой рекурсией:

(define factorial
  (letrec ((fact (lambda (x accum)
                   (if (= x 0) accum
                       (fact (- x 1) (* accum x))))))
    (lambda (x)
      (fact x 1))))

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

Кайл Кронин
источник
4
+1 за упоминание самого важного аспекта хвостовых рекурсий, заключающегося в том, что они могут быть преобразованы в итеративную форму и, таким образом, превращены в форму сложности памяти O (1).
К.Гатак
1
@KGhatak не совсем; Ответ правильно говорит о «постоянном пространстве стека», а не о памяти вообще. не придираться, просто чтобы убедиться, что нет недопонимания. например, хвост-рекурсивный список-хвост-мутированиеlist-reverse будет выполняться в пространстве постоянного стека, но создаст и увеличит структуру данных в куче. Обход дерева может использовать моделируемый стек в качестве дополнительного аргумента. и т. д.
Уилл Несс
45

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

Обычно в рекурсии у вас есть базовый случай, который останавливает рекурсивные вызовы и начинает выталкивать стек вызовов. Чтобы использовать классический пример, хотя и больше C-ish, чем Lisp, функция факториала иллюстрирует хвостовую рекурсию. Рекурсивный вызов происходит после проверки условия базового случая.

factorial(x, fac=1) {
  if (x == 1)
     return fac;
   else
     return factorial(x-1, x*fac);
}

Первоначальный вызов факториала был бы factorial(n)где fac=1(значение по умолчанию), а n - это число, для которого нужно рассчитать факториал.

Питер Мейер
источник
Мне показалось, что ваше объяснение проще всего понять, но если это что-то и нужно, то хвостовая рекурсия полезна только для функций с одним оператором в базовых случаях. Рассмотрим такой метод, как этот postimg.cc/5Yg3Cdjn . Примечание: внешний else- это шаг, который вы можете назвать «базовым случаем», но он охватывает несколько строк. Я вас неправильно понимаю или мое предположение верно? Хвостовая рекурсия хороша только для одного лайнера?
Я хочу ответы
2
@IWantAnswers - Нет, тело функции может быть сколь угодно большим. Все, что требуется для хвостового вызова, - это то, что ветка, в которой он находится, вызывает функцию как самое последнее, что она делает, и возвращает результат вызова функции. factorialПример просто классический простой пример, это все.
TJ Crowder
28

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

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

Крис Смит
источник
21

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

Очень просто и интуитивно понятно.

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

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

public static int factorial(int mynumber) {
    if (mynumber == 1) {
        return 1;
    } else {            
        return mynumber * factorial(--mynumber);
    }
}

public static int tail_factorial(int mynumber, int sofar) {
    if (mynumber == 1) {
        return sofar;
    } else {
        return tail_factorial(--mynumber, sofar * mynumber);
    }
}
AbuZubair
источник
3
0! 1. Так что «mynumber == 1» должно быть «mynumber == 0».
Полерто
19

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

Сравнение примеров, представленных в Python:

def recsum(x):
 if x == 1:
  return x
 else:
  return x + recsum(x - 1)

^ RECURSION

def tailrecsum(x, running_total=0):
  if x == 0:
    return running_total
  else:
    return tailrecsum(x - 1, running_total + x)

^ ХВОСТ РЕКУРСИЯ

Как вы можете видеть , в общем рекурсивной версии, окончательный вызов в блоке кода является x + recsum(x - 1). Таким образом, после вызова recsumметода есть еще одна операция x + ...

Однако в хвостовой рекурсивной версии последний вызов (или хвостовой вызов) в блоке кода tailrecsum(x - 1, running_total + x) означает, что последний вызов сделан самому методу, и после этого никакой операции не происходит.

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

РЕДАКТИРОВАТЬ

NB. Помните, что приведенный выше пример написан на Python, среда выполнения которого не поддерживает TCO. Это всего лишь пример, чтобы объяснить суть. TCO поддерживается на таких языках, как Scheme, Haskell и т. Д.

Abhiroop Sarkar
источник
12

В Java возможна хвостовая рекурсивная реализация функции Фибоначчи:

public int tailRecursive(final int n) {
    if (n <= 2)
        return 1;
    return tailRecursiveAux(n, 1, 1);
}

private int tailRecursiveAux(int n, int iter, int acc) {
    if (iter == n)
        return acc;
    return tailRecursiveAux(n, ++iter, acc + iter);
}

Сравните это со стандартной рекурсивной реализацией:

public int recursive(final int n) {
    if (n <= 2)
        return 1;
    return recursive(n - 1) + recursive(n - 2);
}
jorgetown
источник
1
Это возвращает неправильные результаты для меня, для ввода 8 я получаю 36, это должно быть 21. Я что-то упустил? Я использую Java и скопировать его.
Альберто Закканьи
1
Это возвращает SUM (i) для i в [1, n]. Ничего общего с Фиббоначи. Для Fibbo, вам нужны тесты , которые вычитают iterк accкогда iter < (n-1).
Асколейн
10

Я не программист на Лиспе, но думаю, это поможет.

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

Мэтт Гамильтон
источник
10

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

(defun ! (n &optional (product 1))
    (if (zerop n) product
        (! (1- n) (* product n))))

А потом для удовольствия вы можете попробовать (format nil "~R" (! 25))

user922475
источник
9

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

Так что это хвостовая рекурсия, т. Е. N (x - 1, p * x) - это последний оператор в функции, где компилятор умен, чтобы выяснить, что его можно оптимизировать для цикла for (factorial). Второй параметр p несет значение промежуточного продукта.

function N(x, p) {
   return x == 1 ? p : N(x - 1, p * x);
}

Это нерекурсивный способ написания вышеупомянутой факториальной функции (хотя некоторые компиляторы C ++ в любом случае могут ее оптимизировать).

function N(x) {
   return x == 1 ? 1 : x * N(x - 1);
}

но это не так

function F(x) {
  if (x == 1) return 0;
  if (x == 2) return 1;
  return F(x - 1) + F(x - 2);
}

Я написал длинный пост под названием « Понимание рекурсии хвоста - Visual Studio C ++ - представление сборки »

введите описание изображения здесь

SteakOverCooked
источник
1
Как ваша функция N хвостовой рекурсии?
Фабиан Пийке
N (x-1) - это последнее утверждение в функции, где компилятор умен, чтобы понять, что его можно оптимизировать для цикла for (factorial)
doctorlai
Меня беспокоит то, что ваша функция N - это именно функция recsum из принятого ответа по этой теме (за исключением того, что это сумма, а не произведение), и что recsum называется нерекурсивным?
Фабиан Пийке
8

Вот версия Perl 5 tailrecsumфункции, упомянутой ранее.

sub tail_rec_sum($;$){
  my( $x,$running_total ) = (@_,0);

  return $running_total unless $x;

  @_ = ($x-1,$running_total+$x);
  goto &tail_rec_sum; # throw away current stack frame
}
Брэд Гилберт
источник
8

Это выдержка из структуры и интерпретации компьютерных программ о хвостовой рекурсии.

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

Одна из причин, по которой различие между процессом и процедурой может сбивать с толку, заключается в том, что большинство реализаций общих языков (включая Ada, Pascal и C) спроектированы таким образом, что интерпретация любой рекурсивной процедуры потребляет объем памяти, который увеличивается с ростом число вызовов процедур, даже если описанный процесс в принципе является итеративным. Как следствие, эти языки могут описывать итерационные процессы только путем обращения к специальным «циклическим конструкциям», таким как do, repeat, before, for и while. Реализация Схемы не разделяет этот недостаток. Он будет выполнять итеративный процесс в постоянном пространстве, даже если итерационный процесс описывается рекурсивной процедурой. Реализация с этим свойством называется хвостовой рекурсией.В хвостовой рекурсивной реализации итерация может быть выражена с использованием обычного механизма вызова процедуры, так что специальные конструкции итерации полезны только как синтаксический сахар.

ayushgp
источник
1
Я прочитал все ответы здесь, и все же это самое ясное объяснение, которое касается действительно глубокого ядра этой концепции. Это объясняет это так прямо, что все выглядит так просто и понятно. Прости мою грубость, пожалуйста. Это как-то заставляет меня чувствовать, что другие ответы просто не бьют по голове. Я думаю, именно поэтому SICP имеет значение.
englealuze
8

Рекурсивная функция - это функция, которая вызывает сама

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

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

Я объясню и простую рекурсивную функцию, и хвостовую рекурсивную функцию

Для того, чтобы написать простую рекурсивную функцию

  1. Первое, что нужно рассмотреть, это когда вы решите выйти из цикла, который является циклом if
  2. Во-вторых, что делать, если мы являемся нашей собственной функцией

Из приведенного примера:

public static int fact(int n){
  if(n <=1)
     return 1;
  else 
     return n * fact(n-1);
}

Из приведенного выше примера

if(n <=1)
     return 1;

Является ли решающим фактором, когда выйти из цикла

else 
     return n * fact(n-1);

Фактическая обработка должна быть сделана

Позвольте мне решить задачу один за другим для облегчения понимания.

Давайте посмотрим, что произойдет внутри, если я бегу fact(4)

  1. Подставляя n = 4
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifцикл завершается неудачно, поэтому он переходит в elseцикл, поэтому он возвращает4 * fact(3)

  1. В стековой памяти мы имеем 4 * fact(3)

    Подставляя n = 3

public static int fact(3){
  if(3 <=1)
     return 1;
  else 
     return 3 * fact(3-1);
}

Ifцикл завершается неудачно, поэтому он переходит в elseцикл

так что возвращается 3 * fact(2)

Помните, мы назвали `` `4 * fact (3)` `

Выход для fact(3) = 3 * fact(2)

Пока стек имеет 4 * fact(3) = 4 * 3 * fact(2)

  1. В стековой памяти мы имеем 4 * 3 * fact(2)

    Подставляя n = 2

public static int fact(2){
  if(2 <=1)
     return 1;
  else 
     return 2 * fact(2-1);
}

Ifцикл завершается неудачно, поэтому он переходит в elseцикл

так что возвращается 2 * fact(1)

Помните, мы звонили 4 * 3 * fact(2)

Выход для fact(2) = 2 * fact(1)

Пока стек имеет 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. В стековой памяти мы имеем 4 * 3 * 2 * fact(1)

    Подставляя n = 1

public static int fact(1){
  if(1 <=1)
     return 1;
  else 
     return 1 * fact(1-1);
}

If петля верна

так что возвращается 1

Помните, мы звонили 4 * 3 * 2 * fact(1)

Выход для fact(1) = 1

Пока стек имеет 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

Наконец, результат факта (4) = 4 * 3 * 2 * 1 = 24

введите описание изображения здесь

Хвостовая рекурсия будет

public static int fact(x, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(x-1, running_total*x);
    }
}

  1. Подставляя n = 4
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifцикл завершается неудачно, поэтому он переходит в elseцикл, поэтому он возвращаетfact(3, 4)

  1. В стековой памяти мы имеем fact(3, 4)

    Подставляя n = 3

public static int fact(3, running_total=4) {
    if (x==1) {
        return running_total;
    } else {
        return fact(3-1, 4*3);
    }
}

Ifцикл завершается неудачно, поэтому он переходит в elseцикл

так что возвращается fact(2, 12)

  1. В стековой памяти мы имеем fact(2, 12)

    Подставляя n = 2

public static int fact(2, running_total=12) {
    if (x==1) {
        return running_total;
    } else {
        return fact(2-1, 12*2);
    }
}

Ifцикл завершается неудачно, поэтому он переходит в elseцикл

так что возвращается fact(1, 24)

  1. В стековой памяти мы имеем fact(1, 24)

    Подставляя n = 1

public static int fact(1, running_total=24) {
    if (x==1) {
        return running_total;
    } else {
        return fact(1-1, 24*1);
    }
}

If петля верна

так что возвращается running_total

Выход для running_total = 24

Наконец, результат факта (4,1) = 24

введите описание изображения здесь

Nursnaaz
источник
7

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

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

Спасибо
источник
1
оно не нарушается при интерпретации расщепления личности . :) Общество разума; Разум как общество. :)
Уилл Несс
Вот Это Да! Теперь это еще один способ думать об этом
sutanu dalui
7

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

Рассмотрим проблему вычисления факториала числа.

Прямой подход будет:

  factorial(n):

    if n==0 then 1

    else n*factorial(n-1)

Предположим, вы звоните факториалом (4). Дерево рекурсии будет:

       factorial(4)
       /        \
      4      factorial(3)
     /             \
    3          factorial(2)
   /                  \
  2                factorial(1)
 /                       \
1                       factorial(0)
                            \
                             1    

Максимальная глубина рекурсии в приведенном выше случае составляет O (n).

Однако рассмотрим следующий пример:

factAux(m,n):
if n==0  then m;
else     factAux(m*n,n-1);

factTail(n):
   return factAux(1,n);

Дерево рекурсии для factTail (4) будет:

factTail(4)
   |
factAux(1,4)
   |
factAux(4,3)
   |
factAux(12,2)
   |
factAux(24,1)
   |
factAux(24,0)
   |
  24

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

coding_ninza
источник
7

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

Рохит Гарг
источник
6

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

def recursiveFunction(some_params):
    # some code here
    return recursiveFunction(some_args)
    # no code after the return statement

Компиляторы и интерпретаторы, которые реализуют оптимизацию хвостовых вызовов или устранение хвостовых вызовов, могут оптимизировать рекурсивный код для предотвращения переполнения стека. Если ваш компилятор или интерпретатор не реализует оптимизацию хвостовых вызовов (например, интерпретатор CPython), то нет никакой дополнительной выгоды для написания вашего кода таким способом.

Например, это стандартная рекурсивная факториальная функция в Python:

def factorial(number):
    if number == 1:
        # BASE CASE
        return 1
    else:
        # RECURSIVE CASE
        # Note that `number *` happens *after* the recursive call.
        # This means that this is *not* tail call recursion.
        return number * factorial(number - 1)

И это рекурсивная версия хвостового вызова факториальной функции:

def factorial(number, accumulator=1):
    if number == 0:
        # BASE CASE
        return accumulator
    else:
        # RECURSIVE CASE
        # There's no code after the recursive call.
        # This is tail call recursion:
        return factorial(number - 1, number * accumulator)
print(factorial(5))

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

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

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

Переполнения стека возникают, когда в стеке вызовов слишком много объектов кадра. Объект кадра помещается в стек вызовов при вызове функции и выталкивается из стека вызовов при возврате функции. Объекты Frame содержат информацию, такую ​​как локальные переменные и строку кода, к которой нужно вернуться, когда функция вернется.

Если ваша рекурсивная функция делает слишком много рекурсивных вызовов без возврата, стек вызовов может превысить свой предел объекта фрейма. (Число зависит от платформы; в Python по умолчанию это 1000 кадровых объектов.) Это вызывает переполнение стека ошибку . (Эй, отсюда и название этого сайта!)

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

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

оборота Al Sweigart
источник
Msgstr "Хвостовая рекурсия (также называемая оптимизацией хвостового вызова или устранением хвостового вызова)". Нет; Исключение хвостовых вызовов или оптимизация хвостовых вызовов - это то, что вы можете применить к хвостовой рекурсивной функции, но это не одно и то же. Вы можете написать хвостовые рекурсивные функции в Python (как вы показываете), но они не более эффективны, чем нерекурсивные функции, потому что Python не выполняет оптимизацию хвостовых вызовов.
chepner
Значит ли это, что если кому-то удастся оптимизировать веб-сайт и сделать рекурсивный вызов хвостовым рекурсивным, у нас больше не будет сайта StackOverflow ?! Это ужасно.
Наджиб Мами
5

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

Вот статья с некоторыми примерами на C #, F # и C ++ \ CLI: Приключения в Tail Recursion на C #, F # и C ++ \ CLI .

C # не оптимизирует для рекурсии хвостового вызова, тогда как F # делает.

Принципиальные различия включают в себя циклы и лямбда-исчисление. C # разработан с учетом циклов, тогда как F # построен на принципах лямбда-исчисления. За очень хорошую (и бесплатную) книгу о принципах лямбда-исчисления см. « Структура и интерпретация компьютерных программ» Абельсона, Суссмана и Суссмана .

Относительно хвостовых вызовов в F #, для очень хорошей вводной статьи, см. Подробное введение в хвостовые вызовы в F # . Наконец, вот статья, в которой рассматривается различие между рекурсией без хвоста и рекурсией с использованием хвостового вызова (в F #): рекурсия с хвостом против рекурсии без хвоста в F sharp .

Если вы хотите прочитать о некоторых конструктивных различиях рекурсии хвостового вызова между C # и F #, см. Генерация кода операции Tail-Call в C # и F # .

Если вам нужно знать, какие условия мешают компилятору C # выполнять оптимизацию хвостового вызова, см. Эту статью: Условия хвостового вызова JIT CLR .

devinbost
источник
4

Существует два основных вида рекурсии: рекурсия головы и хвостовая рекурсия.

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

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

Взято из этого супер классного поста. Пожалуйста, подумайте над прочтением.

Абдулла Хан
источник
4

Рекурсия означает функцию, вызывающую себя. Например:

(define (un-ended name)
  (un-ended 'me)
  (print "How can I get here?"))

Tail-Recursion означает рекурсию, завершающую функцию:

(define (un-ended name)
  (print "hello")
  (un-ended 'me))

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

(define (map lst op)
  (define (helper done left)
    (if (nil? left)
        done
        (helper (cons (op (car left))
                      done)
                (cdr left))))
  (reverse (helper '() lst)))

В вспомогательной процедуре последнее, что она делает, если left не равно nil, - это вызывает себя (ПОСЛЕ того, что что-то минует и что-то cdr). Это в основном, как вы отображаете список.

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

Magice
источник
3

На этот вопрос есть много хороших ответов ... но я не могу не согласиться с альтернативным подходом к определению «рекурсии хвоста» или, по крайней мере, «правильной рекурсии хвоста». А именно: следует ли рассматривать его как свойство определенного выражения в программе? Или следует рассматривать это как свойство реализации языка программирования ?

Более подробно о последнем представлении есть классическая статья Уилла Клингера «Правильная рекурсия хвоста и эффективность использования пространства» (PLDI 1998), в которой «правильная хвостовая рекурсия» определяется как свойство реализации языка программирования. Определение построено так, чтобы позволить игнорировать подробности реализации (например, представлен ли стек вызовов на самом деле через стек времени выполнения или через выделенный в куче связанный список кадров).

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

Статья заслуживает тщательного изучения по ряду причин:

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

    Вот эти определения, просто чтобы дать представление о тексте:

    Определение 1 В хвостовых выражениях из программы , написанной на схеме ядер определяются индуктивно следующим образом .

    1. Тело лямбда-выражения является хвостовым выражением
    2. Если (if E0 E1 E2)является хвостовым выражением, то оба E1и E2являются хвостовыми выражениями.
    3. Ничто другое не является выражением хвоста.

    Определение 2 хвост вызов является хвост выражения , которое является вызовом процедуры.

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

  • Она обеспечивает формальные определения для шести различных «машин» для оценки сердечника схемы, где каждая машина имеет такое же наблюдаемое поведение , за исключением для асимптотического класса пространства сложности , что каждый находится.

    Например, после предоставления определений для машин с соответственно: 1. управлением памятью на основе стека, 2. сборкой мусора, но без хвостовых вызовов, 3. сборкой мусора и хвостовыми вызовами, документ продолжает работу с еще более продвинутыми стратегиями управления хранением, такими как 4. «хвостовая рекурсия evlis», в которой нет необходимости сохранять среду во время оценки последнего аргумента подвыражения в хвостовом вызове; 5. сокращать среду замыкания только до свободных переменных этого замыкания; 6. так называемая семантика «безопасный для космоса», как определено Аппелем и Шао .

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


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

pnkfelix
источник
1

Многие люди уже объяснили рекурсию здесь. Я хотел бы привести пару соображений о некоторых преимуществах, которые дает рекурсия из книги Риккардо Террелла «Параллелизм в .NET, Современные шаблоны параллельного и параллельного программирования»:

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

Вот также некоторые интересные заметки из той же книги о хвостовой рекурсии:

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

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

Вадим Сергеевич
источник